diff --git a/internal/datastore/common/schema.go b/internal/datastore/common/schema.go index 8f31f929a6..7cff99d578 100644 --- a/internal/datastore/common/schema.go +++ b/internal/datastore/common/schema.go @@ -40,6 +40,9 @@ type SchemaInformation struct { // WithIntegrityColumns is a flag to indicate if the schema has integrity columns. WithIntegrityColumns bool `debugmap:"visible"` + + // ExpirationDisabled is a flag to indicate whether expiration support is disabled. + ExpirationDisabled bool `debugmap:"visible"` } func (si SchemaInformation) debugValidate() { diff --git a/internal/datastore/common/schema_options.go b/internal/datastore/common/schema_options.go index 3aed7f64e8..fa7639776e 100644 --- a/internal/datastore/common/schema_options.go +++ b/internal/datastore/common/schema_options.go @@ -49,6 +49,7 @@ func (s *SchemaInformation) ToOption() SchemaInformationOption { to.NowFunction = s.NowFunction to.ColumnOptimization = s.ColumnOptimization to.WithIntegrityColumns = s.WithIntegrityColumns + to.ExpirationDisabled = s.ExpirationDisabled } } @@ -73,6 +74,7 @@ func (s SchemaInformation) DebugMap() map[string]any { debugMap["NowFunction"] = helpers.DebugValue(s.NowFunction, false) debugMap["ColumnOptimization"] = helpers.DebugValue(s.ColumnOptimization, false) debugMap["WithIntegrityColumns"] = helpers.DebugValue(s.WithIntegrityColumns, false) + debugMap["ExpirationDisabled"] = helpers.DebugValue(s.ExpirationDisabled, false) return debugMap } @@ -217,3 +219,10 @@ func WithWithIntegrityColumns(withIntegrityColumns bool) SchemaInformationOption s.WithIntegrityColumns = withIntegrityColumns } } + +// WithExpirationDisabled returns an option that can set ExpirationDisabled on a SchemaInformation +func WithExpirationDisabled(expirationDisabled bool) SchemaInformationOption { + return func(s *SchemaInformation) { + s.ExpirationDisabled = expirationDisabled + } +} diff --git a/internal/datastore/common/sql.go b/internal/datastore/common/sql.go index a0a92f65fd..f86131bcaa 100644 --- a/internal/datastore/common/sql.go +++ b/internal/datastore/common/sql.go @@ -106,13 +106,7 @@ func NewSchemaQueryFiltererForRelationshipsSelect(schema SchemaInformation, filt log.Warn().Msg("SchemaQueryFilterer: filterMaximumIDCount not set, defaulting to 100") } - // Filter out any expired relationships. - // TODO(jschorr): Make this depend on whether expiration is necessary. - queryBuilder := sq.StatementBuilder.PlaceholderFormat(schema.PlaceholderFormat).Select().Where(sq.Or{ - sq.Eq{schema.ColExpiration: nil}, - sq.Expr(schema.ColExpiration + " > " + schema.NowFunction + "()"), - }) - + queryBuilder := sq.StatementBuilder.PlaceholderFormat(schema.PlaceholderFormat).Select() return SchemaQueryFilterer{ schema: schema, queryBuilder: queryBuilder, @@ -134,13 +128,6 @@ func NewSchemaQueryFiltererWithStartingQuery(schema SchemaInformation, startingQ log.Warn().Msg("SchemaQueryFilterer: filterMaximumIDCount not set, defaulting to 100") } - // Filter out any expired relationships. - // TODO(jschorr): Make this depend on whether expiration is necessary. - startingQuery = startingQuery.Where(sq.Or{ - sq.Eq{schema.ColExpiration: nil}, - sq.Expr(schema.ColExpiration + " > " + schema.NowFunction + "()"), - }) - return SchemaQueryFilterer{ schema: schema, queryBuilder: startingQuery, @@ -179,7 +166,21 @@ func (sqf SchemaQueryFilterer) UnderlyingQueryBuilder() sq.SelectBuilder { spiceerrors.DebugAssert(func() bool { return sqf.isCustomQuery }, "UnderlyingQueryBuilder should only be called on custom queries") - return sqf.queryBuilder + return sqf.queryBuilderWithExpirationFilter(false) +} + +// queryBuilderWithExpirationFilter returns the query builder with the expiration filter applied, when necessary. +// Note that this adds the clause to the existing builder. +func (sqf SchemaQueryFilterer) queryBuilderWithExpirationFilter(skipExpiration bool) sq.SelectBuilder { + if sqf.schema.ExpirationDisabled || skipExpiration { + return sqf.queryBuilder + } + + // Filter out any expired relationships. + return sqf.queryBuilder.Where(sq.Or{ + sq.Eq{sqf.schema.ColExpiration: nil}, + sq.Expr(sqf.schema.ColExpiration + " > " + sqf.schema.NowFunction + "()"), + }) } func (sqf SchemaQueryFilterer) TupleOrder(order options.SortOrder) SchemaQueryFilterer { @@ -470,6 +471,7 @@ func (sqf SchemaQueryFilterer) FilterWithRelationshipsFilter(filter datastore.Re if filter.OptionalExpirationOption == datastore.ExpirationFilterOptionHasExpiration { csqf.queryBuilder = csqf.queryBuilder.Where(sq.NotEq{csqf.schema.ColExpiration: nil}) + spiceerrors.DebugAssert(func() bool { return !sqf.schema.ExpirationDisabled }, "expiration filter requested but schema does not support expiration") } else if filter.OptionalExpirationOption == datastore.ExpirationFilterOptionNoExpiration { csqf.queryBuilder = csqf.queryBuilder.Where(sq.Eq{csqf.schema.ColExpiration: nil}) } @@ -666,6 +668,7 @@ func (exc QueryRelationshipsExecutor) ExecuteQuery( builder := RelationshipsQueryBuilder{ Schema: query.schema, SkipCaveats: queryOpts.SkipCaveats, + SkipExpiration: queryOpts.SkipExpiration, filteringValues: query.filteringColumnTracker, baseQueryBuilder: query, } @@ -676,8 +679,9 @@ func (exc QueryRelationshipsExecutor) ExecuteQuery( // RelationshipsQueryBuilder is a builder for producing the SQL and arguments necessary for reading // relationships. type RelationshipsQueryBuilder struct { - Schema SchemaInformation - SkipCaveats bool + Schema SchemaInformation + SkipCaveats bool + SkipExpiration bool filteringValues map[string]ColumnTracker baseQueryBuilder SchemaQueryFilterer @@ -703,7 +707,9 @@ func (b RelationshipsQueryBuilder) SelectSQL() (string, []any, error) { columnNamesToSelect = append(columnNamesToSelect, b.Schema.ColCaveatName, b.Schema.ColCaveatContext) } - columnNamesToSelect = append(columnNamesToSelect, b.Schema.ColExpiration) + if !b.SkipExpiration && !b.Schema.ExpirationDisabled { + columnNamesToSelect = append(columnNamesToSelect, b.Schema.ColExpiration) + } if b.Schema.WithIntegrityColumns { columnNamesToSelect = append(columnNamesToSelect, b.Schema.ColIntegrityKeyID, b.Schema.ColIntegrityHash, b.Schema.ColIntegrityTimestamp) @@ -713,7 +719,7 @@ func (b RelationshipsQueryBuilder) SelectSQL() (string, []any, error) { columnNamesToSelect = append(columnNamesToSelect, "1") } - sqlBuilder := b.baseQueryBuilder.queryBuilder + sqlBuilder := b.baseQueryBuilder.queryBuilderWithExpirationFilter(b.SkipExpiration) sqlBuilder = sqlBuilder.Columns(columnNamesToSelect...) return sqlBuilder.ToSql() @@ -788,7 +794,9 @@ func ColumnsToSelect[CN any, CC any, EC any]( colsToSelect = append(colsToSelect, caveatName, caveatCtx) } - colsToSelect = append(colsToSelect, expiration) + if !b.SkipExpiration && !b.Schema.ExpirationDisabled { + colsToSelect = append(colsToSelect, expiration) + } if b.Schema.WithIntegrityColumns { colsToSelect = append(colsToSelect, integrityKeyID, integrityHash, timestamp) diff --git a/internal/datastore/common/sql_test.go b/internal/datastore/common/sql_test.go index 6b2e2c40a4..cee75c6ce4 100644 --- a/internal/datastore/common/sql_test.go +++ b/internal/datastore/common/sql_test.go @@ -19,245 +19,256 @@ var toCursor = options.ToCursor func TestSchemaQueryFilterer(t *testing.T) { tests := []struct { - name string - run func(filterer SchemaQueryFilterer) SchemaQueryFilterer - expectedSQL string - expectedArgs []any - expectedStaticColumns []string + name string + run func(filterer SchemaQueryFilterer) SchemaQueryFilterer + expectedSQL string + expectedArgs []any + expectedStaticColumns []string + withExpirationDisabled bool }{ { - "relation filter", - func(filterer SchemaQueryFilterer) SchemaQueryFilterer { + name: "relation filter", + run: func(filterer SchemaQueryFilterer) SchemaQueryFilterer { + return filterer.FilterToRelation("somerelation") + }, + expectedSQL: "SELECT * WHERE relation = ? AND (expiration IS NULL OR expiration > NOW())", + expectedArgs: []any{"somerelation"}, + expectedStaticColumns: []string{"relation"}, + }, + { + name: "relation filter without expiration", + run: func(filterer SchemaQueryFilterer) SchemaQueryFilterer { return filterer.FilterToRelation("somerelation") }, - "SELECT * WHERE (expiration IS NULL OR expiration > NOW()) AND relation = ?", - []any{"somerelation"}, - []string{"relation"}, + expectedSQL: "SELECT * WHERE relation = ?", + expectedArgs: []any{"somerelation"}, + expectedStaticColumns: []string{"relation"}, + withExpirationDisabled: true, }, { - "resource ID filter", - func(filterer SchemaQueryFilterer) SchemaQueryFilterer { + name: "resource ID filter", + run: func(filterer SchemaQueryFilterer) SchemaQueryFilterer { return filterer.FilterToResourceID("someresourceid") }, - "SELECT * WHERE (expiration IS NULL OR expiration > NOW()) AND object_id = ?", - []any{"someresourceid"}, - []string{"object_id"}, + expectedSQL: "SELECT * WHERE object_id = ? AND (expiration IS NULL OR expiration > NOW())", + expectedArgs: []any{"someresourceid"}, + expectedStaticColumns: []string{"object_id"}, }, { - "resource IDs filter", - func(filterer SchemaQueryFilterer) SchemaQueryFilterer { + name: "resource IDs filter", + run: func(filterer SchemaQueryFilterer) SchemaQueryFilterer { return filterer.MustFilterWithResourceIDPrefix("someprefix") }, - "SELECT * WHERE (expiration IS NULL OR expiration > NOW()) AND object_id LIKE ?", - []any{"someprefix%"}, - []string{}, + expectedSQL: "SELECT * WHERE object_id LIKE ? AND (expiration IS NULL OR expiration > NOW())", + expectedArgs: []any{"someprefix%"}, + expectedStaticColumns: []string{}, }, { - "resource IDs prefix filter", - func(filterer SchemaQueryFilterer) SchemaQueryFilterer { + name: "resource IDs prefix filter", + run: func(filterer SchemaQueryFilterer) SchemaQueryFilterer { return filterer.MustFilterToResourceIDs([]string{"someresourceid", "anotherresourceid"}) }, - "SELECT * WHERE (expiration IS NULL OR expiration > NOW()) AND object_id IN (?,?)", - []any{"someresourceid", "anotherresourceid"}, - []string{}, + expectedSQL: "SELECT * WHERE object_id IN (?,?) AND (expiration IS NULL OR expiration > NOW())", + expectedArgs: []any{"someresourceid", "anotherresourceid"}, + expectedStaticColumns: []string{}, }, { - "resource type filter", - func(filterer SchemaQueryFilterer) SchemaQueryFilterer { + name: "resource type filter", + run: func(filterer SchemaQueryFilterer) SchemaQueryFilterer { return filterer.FilterToResourceType("sometype") }, - "SELECT * WHERE (expiration IS NULL OR expiration > NOW()) AND ns = ?", - []any{"sometype"}, - []string{"ns"}, + expectedSQL: "SELECT * WHERE ns = ? AND (expiration IS NULL OR expiration > NOW())", + expectedArgs: []any{"sometype"}, + expectedStaticColumns: []string{"ns"}, }, { - "resource filter", - func(filterer SchemaQueryFilterer) SchemaQueryFilterer { + name: "resource filter", + run: func(filterer SchemaQueryFilterer) SchemaQueryFilterer { return filterer.FilterToResourceType("sometype").FilterToResourceID("someobj").FilterToRelation("somerel") }, - "SELECT * WHERE (expiration IS NULL OR expiration > NOW()) AND ns = ? AND object_id = ? AND relation = ?", - []any{"sometype", "someobj", "somerel"}, - []string{"ns", "object_id", "relation"}, + expectedSQL: "SELECT * WHERE ns = ? AND object_id = ? AND relation = ? AND (expiration IS NULL OR expiration > NOW())", + expectedArgs: []any{"sometype", "someobj", "somerel"}, + expectedStaticColumns: []string{"ns", "object_id", "relation"}, }, { - "relationships filter with no IDs or relations", - func(filterer SchemaQueryFilterer) SchemaQueryFilterer { + name: "relationships filter with no IDs or relations", + run: func(filterer SchemaQueryFilterer) SchemaQueryFilterer { return filterer.MustFilterWithRelationshipsFilter(datastore.RelationshipsFilter{ OptionalResourceType: "sometype", }) }, - "SELECT * WHERE (expiration IS NULL OR expiration > NOW()) AND ns = ?", - []any{"sometype"}, - []string{"ns"}, + expectedSQL: "SELECT * WHERE ns = ? AND (expiration IS NULL OR expiration > NOW())", + expectedArgs: []any{"sometype"}, + expectedStaticColumns: []string{"ns"}, }, { - "relationships filter with single ID", - func(filterer SchemaQueryFilterer) SchemaQueryFilterer { + name: "relationships filter with single ID", + run: func(filterer SchemaQueryFilterer) SchemaQueryFilterer { return filterer.MustFilterWithRelationshipsFilter(datastore.RelationshipsFilter{ OptionalResourceType: "sometype", OptionalResourceIds: []string{"someid"}, }) }, - "SELECT * WHERE (expiration IS NULL OR expiration > NOW()) AND ns = ? AND object_id IN (?)", - []any{"sometype", "someid"}, - []string{"ns", "object_id"}, + expectedSQL: "SELECT * WHERE ns = ? AND object_id IN (?) AND (expiration IS NULL OR expiration > NOW())", + expectedArgs: []any{"sometype", "someid"}, + expectedStaticColumns: []string{"ns", "object_id"}, }, { - "relationships filter with no IDs", - func(filterer SchemaQueryFilterer) SchemaQueryFilterer { + name: "relationships filter with no IDs", + run: func(filterer SchemaQueryFilterer) SchemaQueryFilterer { return filterer.MustFilterWithRelationshipsFilter(datastore.RelationshipsFilter{ OptionalResourceType: "sometype", OptionalResourceIds: []string{}, }) }, - "SELECT * WHERE (expiration IS NULL OR expiration > NOW()) AND ns = ?", - []any{"sometype"}, - []string{"ns"}, + expectedSQL: "SELECT * WHERE ns = ? AND (expiration IS NULL OR expiration > NOW())", + expectedArgs: []any{"sometype"}, + expectedStaticColumns: []string{"ns"}, }, { - "relationships filter with multiple IDs", - func(filterer SchemaQueryFilterer) SchemaQueryFilterer { + name: "relationships filter with multiple IDs", + run: func(filterer SchemaQueryFilterer) SchemaQueryFilterer { return filterer.MustFilterWithRelationshipsFilter(datastore.RelationshipsFilter{ OptionalResourceType: "sometype", OptionalResourceIds: []string{"someid", "anotherid"}, }) }, - "SELECT * WHERE (expiration IS NULL OR expiration > NOW()) AND ns = ? AND object_id IN (?,?)", - []any{"sometype", "someid", "anotherid"}, - []string{"ns"}, + expectedSQL: "SELECT * WHERE ns = ? AND object_id IN (?,?) AND (expiration IS NULL OR expiration > NOW())", + expectedArgs: []any{"sometype", "someid", "anotherid"}, + expectedStaticColumns: []string{"ns"}, }, { - "subjects filter with no IDs or relations", - func(filterer SchemaQueryFilterer) SchemaQueryFilterer { + name: "subjects filter with no IDs or relations", + run: func(filterer SchemaQueryFilterer) SchemaQueryFilterer { return filterer.MustFilterWithSubjectsSelectors(datastore.SubjectsSelector{ OptionalSubjectType: "somesubjectype", }) }, - "SELECT * WHERE (expiration IS NULL OR expiration > NOW()) AND ((subject_ns = ?))", - []any{"somesubjectype"}, - []string{"subject_ns"}, + expectedSQL: "SELECT * WHERE ((subject_ns = ?)) AND (expiration IS NULL OR expiration > NOW())", + expectedArgs: []any{"somesubjectype"}, + expectedStaticColumns: []string{"subject_ns"}, }, { - "multiple subjects filters with just types", - func(filterer SchemaQueryFilterer) SchemaQueryFilterer { + name: "multiple subjects filters with just types", + run: func(filterer SchemaQueryFilterer) SchemaQueryFilterer { return filterer.MustFilterWithSubjectsSelectors(datastore.SubjectsSelector{ OptionalSubjectType: "somesubjectype", }, datastore.SubjectsSelector{ OptionalSubjectType: "anothersubjectype", }) }, - "SELECT * WHERE (expiration IS NULL OR expiration > NOW()) AND ((subject_ns = ?) OR (subject_ns = ?))", - []any{"somesubjectype", "anothersubjectype"}, - []string{}, + expectedSQL: "SELECT * WHERE ((subject_ns = ?) OR (subject_ns = ?)) AND (expiration IS NULL OR expiration > NOW())", + expectedArgs: []any{"somesubjectype", "anothersubjectype"}, + expectedStaticColumns: []string{}, }, { - "subjects filter with single ID", - func(filterer SchemaQueryFilterer) SchemaQueryFilterer { + name: "subjects filter with single ID", + run: func(filterer SchemaQueryFilterer) SchemaQueryFilterer { return filterer.MustFilterWithSubjectsSelectors(datastore.SubjectsSelector{ OptionalSubjectType: "somesubjectype", OptionalSubjectIds: []string{"somesubjectid"}, }) }, - "SELECT * WHERE (expiration IS NULL OR expiration > NOW()) AND ((subject_ns = ? AND subject_object_id IN (?)))", - []any{"somesubjectype", "somesubjectid"}, - []string{"subject_ns", "subject_object_id"}, + expectedSQL: "SELECT * WHERE ((subject_ns = ? AND subject_object_id IN (?))) AND (expiration IS NULL OR expiration > NOW())", + expectedArgs: []any{"somesubjectype", "somesubjectid"}, + expectedStaticColumns: []string{"subject_ns", "subject_object_id"}, }, { - "subjects filter with single ID and no type", - func(filterer SchemaQueryFilterer) SchemaQueryFilterer { + name: "subjects filter with single ID and no type", + run: func(filterer SchemaQueryFilterer) SchemaQueryFilterer { return filterer.MustFilterWithSubjectsSelectors(datastore.SubjectsSelector{ OptionalSubjectIds: []string{"somesubjectid"}, }) }, - "SELECT * WHERE (expiration IS NULL OR expiration > NOW()) AND ((subject_object_id IN (?)))", - []any{"somesubjectid"}, - []string{"subject_object_id"}, + expectedSQL: "SELECT * WHERE ((subject_object_id IN (?))) AND (expiration IS NULL OR expiration > NOW())", + expectedArgs: []any{"somesubjectid"}, + expectedStaticColumns: []string{"subject_object_id"}, }, { - "empty subjects filter", - func(filterer SchemaQueryFilterer) SchemaQueryFilterer { + name: "empty subjects filter", + run: func(filterer SchemaQueryFilterer) SchemaQueryFilterer { return filterer.MustFilterWithSubjectsSelectors(datastore.SubjectsSelector{}) }, - "SELECT * WHERE (expiration IS NULL OR expiration > NOW()) AND ((1=1))", - nil, - []string{}, + expectedSQL: "SELECT * WHERE ((1=1)) AND (expiration IS NULL OR expiration > NOW())", + expectedArgs: nil, + expectedStaticColumns: []string{}, }, { - "subjects filter with multiple IDs", - func(filterer SchemaQueryFilterer) SchemaQueryFilterer { + name: "subjects filter with multiple IDs", + run: func(filterer SchemaQueryFilterer) SchemaQueryFilterer { return filterer.MustFilterWithSubjectsSelectors(datastore.SubjectsSelector{ OptionalSubjectType: "somesubjectype", OptionalSubjectIds: []string{"somesubjectid", "anothersubjectid"}, }) }, - "SELECT * WHERE (expiration IS NULL OR expiration > NOW()) AND ((subject_ns = ? AND subject_object_id IN (?,?)))", - []any{"somesubjectype", "somesubjectid", "anothersubjectid"}, - []string{"subject_ns"}, + expectedSQL: "SELECT * WHERE ((subject_ns = ? AND subject_object_id IN (?,?))) AND (expiration IS NULL OR expiration > NOW())", + expectedArgs: []any{"somesubjectype", "somesubjectid", "anothersubjectid"}, + expectedStaticColumns: []string{"subject_ns"}, }, { - "subjects filter with single ellipsis relation", - func(filterer SchemaQueryFilterer) SchemaQueryFilterer { + name: "subjects filter with single ellipsis relation", + run: func(filterer SchemaQueryFilterer) SchemaQueryFilterer { return filterer.MustFilterWithSubjectsSelectors(datastore.SubjectsSelector{ OptionalSubjectType: "somesubjectype", RelationFilter: datastore.SubjectRelationFilter{}.WithEllipsisRelation(), }) }, - "SELECT * WHERE (expiration IS NULL OR expiration > NOW()) AND ((subject_ns = ? AND subject_relation = ?))", - []any{"somesubjectype", "..."}, - []string{"subject_ns", "subject_relation"}, + expectedSQL: "SELECT * WHERE ((subject_ns = ? AND subject_relation = ?)) AND (expiration IS NULL OR expiration > NOW())", + expectedArgs: []any{"somesubjectype", "..."}, + expectedStaticColumns: []string{"subject_ns", "subject_relation"}, }, { - "subjects filter with single defined relation", - func(filterer SchemaQueryFilterer) SchemaQueryFilterer { + name: "subjects filter with single defined relation", + run: func(filterer SchemaQueryFilterer) SchemaQueryFilterer { return filterer.MustFilterWithSubjectsSelectors(datastore.SubjectsSelector{ OptionalSubjectType: "somesubjectype", RelationFilter: datastore.SubjectRelationFilter{}.WithNonEllipsisRelation("somesubrel"), }) }, - "SELECT * WHERE (expiration IS NULL OR expiration > NOW()) AND ((subject_ns = ? AND subject_relation = ?))", - []any{"somesubjectype", "somesubrel"}, - []string{"subject_ns", "subject_relation"}, + expectedSQL: "SELECT * WHERE ((subject_ns = ? AND subject_relation = ?)) AND (expiration IS NULL OR expiration > NOW())", + expectedArgs: []any{"somesubjectype", "somesubrel"}, + expectedStaticColumns: []string{"subject_ns", "subject_relation"}, }, { - "subjects filter with only non-ellipsis", - func(filterer SchemaQueryFilterer) SchemaQueryFilterer { + name: "subjects filter with only non-ellipsis", + run: func(filterer SchemaQueryFilterer) SchemaQueryFilterer { return filterer.MustFilterWithSubjectsSelectors(datastore.SubjectsSelector{ OptionalSubjectType: "somesubjectype", RelationFilter: datastore.SubjectRelationFilter{}.WithOnlyNonEllipsisRelations(), }) }, - "SELECT * WHERE (expiration IS NULL OR expiration > NOW()) AND ((subject_ns = ? AND subject_relation <> ?))", - []any{"somesubjectype", "..."}, - []string{"subject_ns"}, + expectedSQL: "SELECT * WHERE ((subject_ns = ? AND subject_relation <> ?)) AND (expiration IS NULL OR expiration > NOW())", + expectedArgs: []any{"somesubjectype", "..."}, + expectedStaticColumns: []string{"subject_ns"}, }, { - "subjects filter with defined relation and ellipsis", - func(filterer SchemaQueryFilterer) SchemaQueryFilterer { + name: "subjects filter with defined relation and ellipsis", + run: func(filterer SchemaQueryFilterer) SchemaQueryFilterer { return filterer.MustFilterWithSubjectsSelectors(datastore.SubjectsSelector{ OptionalSubjectType: "somesubjectype", RelationFilter: datastore.SubjectRelationFilter{}.WithNonEllipsisRelation("somesubrel").WithEllipsisRelation(), }) }, - "SELECT * WHERE (expiration IS NULL OR expiration > NOW()) AND ((subject_ns = ? AND (subject_relation = ? OR subject_relation = ?)))", - []any{"somesubjectype", "...", "somesubrel"}, - []string{"subject_ns"}, + expectedSQL: "SELECT * WHERE ((subject_ns = ? AND (subject_relation = ? OR subject_relation = ?))) AND (expiration IS NULL OR expiration > NOW())", + expectedArgs: []any{"somesubjectype", "...", "somesubrel"}, + expectedStaticColumns: []string{"subject_ns"}, }, { - "subjects filter", - func(filterer SchemaQueryFilterer) SchemaQueryFilterer { + name: "subjects filter", + run: func(filterer SchemaQueryFilterer) SchemaQueryFilterer { return filterer.MustFilterWithSubjectsSelectors(datastore.SubjectsSelector{ OptionalSubjectType: "somesubjectype", OptionalSubjectIds: []string{"somesubjectid", "anothersubjectid"}, RelationFilter: datastore.SubjectRelationFilter{}.WithNonEllipsisRelation("somesubrel").WithEllipsisRelation(), }) }, - "SELECT * WHERE (expiration IS NULL OR expiration > NOW()) AND ((subject_ns = ? AND subject_object_id IN (?,?) AND (subject_relation = ? OR subject_relation = ?)))", - []any{"somesubjectype", "somesubjectid", "anothersubjectid", "...", "somesubrel"}, - []string{"subject_ns"}, + expectedSQL: "SELECT * WHERE ((subject_ns = ? AND subject_object_id IN (?,?) AND (subject_relation = ? OR subject_relation = ?))) AND (expiration IS NULL OR expiration > NOW())", + expectedArgs: []any{"somesubjectype", "somesubjectid", "anothersubjectid", "...", "somesubrel"}, + expectedStaticColumns: []string{"subject_ns"}, }, { - "multiple subjects filter", - func(filterer SchemaQueryFilterer) SchemaQueryFilterer { + name: "multiple subjects filter", + run: func(filterer SchemaQueryFilterer) SchemaQueryFilterer { return filterer.MustFilterWithSubjectsSelectors( datastore.SubjectsSelector{ OptionalSubjectType: "somesubjectype", @@ -275,36 +286,36 @@ func TestSchemaQueryFilterer(t *testing.T) { }, ) }, - "SELECT * WHERE (expiration IS NULL OR expiration > NOW()) AND ((subject_ns = ? AND subject_object_id IN (?,?) AND (subject_relation = ? OR subject_relation = ?)) OR (subject_ns = ? AND subject_object_id IN (?,?) AND (subject_relation = ? OR subject_relation = ?)) OR (subject_ns = ? AND subject_relation <> ?))", - []any{"somesubjectype", "a", "b", "...", "somesubrel", "anothersubjecttype", "b", "c", "...", "anotherrel", "thirdsubjectype", "..."}, - []string{}, + expectedSQL: "SELECT * WHERE ((subject_ns = ? AND subject_object_id IN (?,?) AND (subject_relation = ? OR subject_relation = ?)) OR (subject_ns = ? AND subject_object_id IN (?,?) AND (subject_relation = ? OR subject_relation = ?)) OR (subject_ns = ? AND subject_relation <> ?)) AND (expiration IS NULL OR expiration > NOW())", + expectedArgs: []any{"somesubjectype", "a", "b", "...", "somesubrel", "anothersubjecttype", "b", "c", "...", "anotherrel", "thirdsubjectype", "..."}, + expectedStaticColumns: []string{}, }, { - "v1 subject filter with namespace", - func(filterer SchemaQueryFilterer) SchemaQueryFilterer { + name: "v1 subject filter with namespace", + run: func(filterer SchemaQueryFilterer) SchemaQueryFilterer { return filterer.FilterToSubjectFilter(&v1.SubjectFilter{ SubjectType: "subns", }) }, - "SELECT * WHERE (expiration IS NULL OR expiration > NOW()) AND subject_ns = ?", - []any{"subns"}, - []string{"subject_ns"}, + expectedSQL: "SELECT * WHERE subject_ns = ? AND (expiration IS NULL OR expiration > NOW())", + expectedArgs: []any{"subns"}, + expectedStaticColumns: []string{"subject_ns"}, }, { - "v1 subject filter with subject id", - func(filterer SchemaQueryFilterer) SchemaQueryFilterer { + name: "v1 subject filter with subject id", + run: func(filterer SchemaQueryFilterer) SchemaQueryFilterer { return filterer.FilterToSubjectFilter(&v1.SubjectFilter{ SubjectType: "subns", OptionalSubjectId: "subid", }) }, - "SELECT * WHERE (expiration IS NULL OR expiration > NOW()) AND subject_ns = ? AND subject_object_id = ?", - []any{"subns", "subid"}, - []string{"subject_ns", "subject_object_id"}, + expectedSQL: "SELECT * WHERE subject_ns = ? AND subject_object_id = ? AND (expiration IS NULL OR expiration > NOW())", + expectedArgs: []any{"subns", "subid"}, + expectedStaticColumns: []string{"subject_ns", "subject_object_id"}, }, { - "v1 subject filter with relation", - func(filterer SchemaQueryFilterer) SchemaQueryFilterer { + name: "v1 subject filter with relation", + run: func(filterer SchemaQueryFilterer) SchemaQueryFilterer { return filterer.FilterToSubjectFilter(&v1.SubjectFilter{ SubjectType: "subns", OptionalRelation: &v1.SubjectFilter_RelationFilter{ @@ -312,13 +323,13 @@ func TestSchemaQueryFilterer(t *testing.T) { }, }) }, - "SELECT * WHERE (expiration IS NULL OR expiration > NOW()) AND subject_ns = ? AND subject_relation = ?", - []any{"subns", "subrel"}, - []string{"subject_ns", "subject_relation"}, + expectedSQL: "SELECT * WHERE subject_ns = ? AND subject_relation = ? AND (expiration IS NULL OR expiration > NOW())", + expectedArgs: []any{"subns", "subrel"}, + expectedStaticColumns: []string{"subject_ns", "subject_relation"}, }, { - "v1 subject filter with empty relation", - func(filterer SchemaQueryFilterer) SchemaQueryFilterer { + name: "v1 subject filter with empty relation", + run: func(filterer SchemaQueryFilterer) SchemaQueryFilterer { return filterer.FilterToSubjectFilter(&v1.SubjectFilter{ SubjectType: "subns", OptionalRelation: &v1.SubjectFilter_RelationFilter{ @@ -326,13 +337,13 @@ func TestSchemaQueryFilterer(t *testing.T) { }, }) }, - "SELECT * WHERE (expiration IS NULL OR expiration > NOW()) AND subject_ns = ? AND subject_relation = ?", - []any{"subns", "..."}, - []string{"subject_ns", "subject_relation"}, + expectedSQL: "SELECT * WHERE subject_ns = ? AND subject_relation = ? AND (expiration IS NULL OR expiration > NOW())", + expectedArgs: []any{"subns", "..."}, + expectedStaticColumns: []string{"subject_ns", "subject_relation"}, }, { - "v1 subject filter", - func(filterer SchemaQueryFilterer) SchemaQueryFilterer { + name: "v1 subject filter", + run: func(filterer SchemaQueryFilterer) SchemaQueryFilterer { return filterer.FilterToSubjectFilter(&v1.SubjectFilter{ SubjectType: "subns", OptionalSubjectId: "subid", @@ -341,22 +352,44 @@ func TestSchemaQueryFilterer(t *testing.T) { }, }) }, - "SELECT * WHERE (expiration IS NULL OR expiration > NOW()) AND subject_ns = ? AND subject_object_id = ? AND subject_relation = ?", - []any{"subns", "subid", "somerel"}, - []string{"subject_ns", "subject_object_id", "subject_relation"}, + expectedSQL: "SELECT * WHERE subject_ns = ? AND subject_object_id = ? AND subject_relation = ? AND (expiration IS NULL OR expiration > NOW())", + expectedArgs: []any{"subns", "subid", "somerel"}, + expectedStaticColumns: []string{"subject_ns", "subject_object_id", "subject_relation"}, }, { - "limit", - func(filterer SchemaQueryFilterer) SchemaQueryFilterer { + name: "limit", + run: func(filterer SchemaQueryFilterer) SchemaQueryFilterer { return filterer.limit(100) }, - "SELECT * WHERE (expiration IS NULL OR expiration > NOW()) LIMIT 100", - nil, - []string{}, + expectedSQL: "SELECT * WHERE (expiration IS NULL OR expiration > NOW()) LIMIT 100", + expectedArgs: nil, + expectedStaticColumns: []string{}, + }, + { + name: "full resources filter", + run: func(filterer SchemaQueryFilterer) SchemaQueryFilterer { + return filterer.MustFilterWithRelationshipsFilter( + datastore.RelationshipsFilter{ + OptionalResourceType: "someresourcetype", + OptionalResourceIds: []string{"someid", "anotherid"}, + OptionalResourceRelation: "somerelation", + OptionalSubjectsSelectors: []datastore.SubjectsSelector{ + { + OptionalSubjectType: "somesubjectype", + OptionalSubjectIds: []string{"somesubjectid", "anothersubjectid"}, + RelationFilter: datastore.SubjectRelationFilter{}.WithNonEllipsisRelation("somesubrel").WithEllipsisRelation(), + }, + }, + }, + ) + }, + expectedSQL: "SELECT * WHERE ns = ? AND relation = ? AND object_id IN (?,?) AND ((subject_ns = ? AND subject_object_id IN (?,?) AND (subject_relation = ? OR subject_relation = ?))) AND (expiration IS NULL OR expiration > NOW())", + expectedArgs: []any{"someresourcetype", "somerelation", "someid", "anotherid", "somesubjectype", "somesubjectid", "anothersubjectid", "...", "somesubrel"}, + expectedStaticColumns: []string{"ns", "relation", "subject_ns"}, }, { - "full resources filter", - func(filterer SchemaQueryFilterer) SchemaQueryFilterer { + name: "full resources filter without expiration", + run: func(filterer SchemaQueryFilterer) SchemaQueryFilterer { return filterer.MustFilterWithRelationshipsFilter( datastore.RelationshipsFilter{ OptionalResourceType: "someresourcetype", @@ -372,52 +405,53 @@ func TestSchemaQueryFilterer(t *testing.T) { }, ) }, - "SELECT * WHERE (expiration IS NULL OR expiration > NOW()) AND ns = ? AND relation = ? AND object_id IN (?,?) AND ((subject_ns = ? AND subject_object_id IN (?,?) AND (subject_relation = ? OR subject_relation = ?)))", - []any{"someresourcetype", "somerelation", "someid", "anotherid", "somesubjectype", "somesubjectid", "anothersubjectid", "...", "somesubrel"}, - []string{"ns", "relation", "subject_ns"}, + expectedSQL: "SELECT * WHERE ns = ? AND relation = ? AND object_id IN (?,?) AND ((subject_ns = ? AND subject_object_id IN (?,?) AND (subject_relation = ? OR subject_relation = ?)))", + expectedArgs: []any{"someresourcetype", "somerelation", "someid", "anotherid", "somesubjectype", "somesubjectid", "anothersubjectid", "...", "somesubrel"}, + expectedStaticColumns: []string{"ns", "relation", "subject_ns"}, + withExpirationDisabled: true, }, { - "order by", - func(filterer SchemaQueryFilterer) SchemaQueryFilterer { + name: "order by", + run: func(filterer SchemaQueryFilterer) SchemaQueryFilterer { return filterer.MustFilterWithRelationshipsFilter( datastore.RelationshipsFilter{ OptionalResourceType: "someresourcetype", }, ).TupleOrder(options.ByResource) }, - "SELECT * WHERE (expiration IS NULL OR expiration > NOW()) AND ns = ? ORDER BY ns, object_id, relation, subject_ns, subject_object_id, subject_relation", - []any{"someresourcetype"}, - []string{"ns"}, + expectedSQL: "SELECT * WHERE ns = ? AND (expiration IS NULL OR expiration > NOW()) ORDER BY ns, object_id, relation, subject_ns, subject_object_id, subject_relation", + expectedArgs: []any{"someresourcetype"}, + expectedStaticColumns: []string{"ns"}, }, { - "after with just namespace", - func(filterer SchemaQueryFilterer) SchemaQueryFilterer { + name: "after with just namespace", + run: func(filterer SchemaQueryFilterer) SchemaQueryFilterer { return filterer.MustFilterWithRelationshipsFilter( datastore.RelationshipsFilter{ OptionalResourceType: "someresourcetype", }, ).After(toCursor(tuple.MustParse("someresourcetype:foo#viewer@user:bar")), options.ByResource) }, - "SELECT * WHERE (expiration IS NULL OR expiration > NOW()) AND ns = ? AND (object_id,relation,subject_ns,subject_object_id,subject_relation) > (?,?,?,?,?)", - []any{"someresourcetype", "foo", "viewer", "user", "bar", "..."}, - []string{"ns"}, + expectedSQL: "SELECT * WHERE ns = ? AND (object_id,relation,subject_ns,subject_object_id,subject_relation) > (?,?,?,?,?) AND (expiration IS NULL OR expiration > NOW())", + expectedArgs: []any{"someresourcetype", "foo", "viewer", "user", "bar", "..."}, + expectedStaticColumns: []string{"ns"}, }, { - "after with just relation", - func(filterer SchemaQueryFilterer) SchemaQueryFilterer { + name: "after with just relation", + run: func(filterer SchemaQueryFilterer) SchemaQueryFilterer { return filterer.MustFilterWithRelationshipsFilter( datastore.RelationshipsFilter{ OptionalResourceRelation: "somerelation", }, ).After(toCursor(tuple.MustParse("someresourcetype:foo#viewer@user:bar")), options.ByResource) }, - "SELECT * WHERE (expiration IS NULL OR expiration > NOW()) AND relation = ? AND (ns,object_id,subject_ns,subject_object_id,subject_relation) > (?,?,?,?,?)", - []any{"somerelation", "someresourcetype", "foo", "user", "bar", "..."}, - []string{"relation"}, + expectedSQL: "SELECT * WHERE relation = ? AND (ns,object_id,subject_ns,subject_object_id,subject_relation) > (?,?,?,?,?) AND (expiration IS NULL OR expiration > NOW())", + expectedArgs: []any{"somerelation", "someresourcetype", "foo", "user", "bar", "..."}, + expectedStaticColumns: []string{"relation"}, }, { - "after with namespace and single resource id", - func(filterer SchemaQueryFilterer) SchemaQueryFilterer { + name: "after with namespace and single resource id", + run: func(filterer SchemaQueryFilterer) SchemaQueryFilterer { return filterer.MustFilterWithRelationshipsFilter( datastore.RelationshipsFilter{ OptionalResourceType: "someresourcetype", @@ -425,26 +459,26 @@ func TestSchemaQueryFilterer(t *testing.T) { }, ).After(toCursor(tuple.MustParse("someresourcetype:foo#viewer@user:bar")), options.ByResource) }, - "SELECT * WHERE (expiration IS NULL OR expiration > NOW()) AND ns = ? AND object_id IN (?) AND (relation,subject_ns,subject_object_id,subject_relation) > (?,?,?,?)", - []any{"someresourcetype", "one", "viewer", "user", "bar", "..."}, - []string{"ns", "object_id"}, + expectedSQL: "SELECT * WHERE ns = ? AND object_id IN (?) AND (relation,subject_ns,subject_object_id,subject_relation) > (?,?,?,?) AND (expiration IS NULL OR expiration > NOW())", + expectedArgs: []any{"someresourcetype", "one", "viewer", "user", "bar", "..."}, + expectedStaticColumns: []string{"ns", "object_id"}, }, { - "after with single resource id", - func(filterer SchemaQueryFilterer) SchemaQueryFilterer { + name: "after with single resource id", + run: func(filterer SchemaQueryFilterer) SchemaQueryFilterer { return filterer.MustFilterWithRelationshipsFilter( datastore.RelationshipsFilter{ OptionalResourceIds: []string{"one"}, }, ).After(toCursor(tuple.MustParse("someresourcetype:foo#viewer@user:bar")), options.ByResource) }, - "SELECT * WHERE (expiration IS NULL OR expiration > NOW()) AND object_id IN (?) AND (ns,relation,subject_ns,subject_object_id,subject_relation) > (?,?,?,?,?)", - []any{"one", "someresourcetype", "viewer", "user", "bar", "..."}, - []string{"object_id"}, + expectedSQL: "SELECT * WHERE object_id IN (?) AND (ns,relation,subject_ns,subject_object_id,subject_relation) > (?,?,?,?,?) AND (expiration IS NULL OR expiration > NOW())", + expectedArgs: []any{"one", "someresourcetype", "viewer", "user", "bar", "..."}, + expectedStaticColumns: []string{"object_id"}, }, { - "after with namespace and resource ids", - func(filterer SchemaQueryFilterer) SchemaQueryFilterer { + name: "after with namespace and resource ids", + run: func(filterer SchemaQueryFilterer) SchemaQueryFilterer { return filterer.MustFilterWithRelationshipsFilter( datastore.RelationshipsFilter{ OptionalResourceType: "someresourcetype", @@ -452,13 +486,13 @@ func TestSchemaQueryFilterer(t *testing.T) { }, ).After(toCursor(tuple.MustParse("someresourcetype:foo#viewer@user:bar")), options.ByResource) }, - "SELECT * WHERE (expiration IS NULL OR expiration > NOW()) AND ns = ? AND object_id IN (?,?) AND (object_id,relation,subject_ns,subject_object_id,subject_relation) > (?,?,?,?,?)", - []any{"someresourcetype", "one", "two", "foo", "viewer", "user", "bar", "..."}, - []string{"ns"}, + expectedSQL: "SELECT * WHERE ns = ? AND object_id IN (?,?) AND (object_id,relation,subject_ns,subject_object_id,subject_relation) > (?,?,?,?,?) AND (expiration IS NULL OR expiration > NOW())", + expectedArgs: []any{"someresourcetype", "one", "two", "foo", "viewer", "user", "bar", "..."}, + expectedStaticColumns: []string{"ns"}, }, { - "after with namespace and relation", - func(filterer SchemaQueryFilterer) SchemaQueryFilterer { + name: "after with namespace and relation", + run: func(filterer SchemaQueryFilterer) SchemaQueryFilterer { return filterer.MustFilterWithRelationshipsFilter( datastore.RelationshipsFilter{ OptionalResourceType: "someresourcetype", @@ -466,24 +500,24 @@ func TestSchemaQueryFilterer(t *testing.T) { }, ).After(toCursor(tuple.MustParse("someresourcetype:foo#viewer@user:bar")), options.ByResource) }, - "SELECT * WHERE (expiration IS NULL OR expiration > NOW()) AND ns = ? AND relation = ? AND (object_id,subject_ns,subject_object_id,subject_relation) > (?,?,?,?)", - []any{"someresourcetype", "somerelation", "foo", "user", "bar", "..."}, - []string{"ns", "relation"}, + expectedSQL: "SELECT * WHERE ns = ? AND relation = ? AND (object_id,subject_ns,subject_object_id,subject_relation) > (?,?,?,?) AND (expiration IS NULL OR expiration > NOW())", + expectedArgs: []any{"someresourcetype", "somerelation", "foo", "user", "bar", "..."}, + expectedStaticColumns: []string{"ns", "relation"}, }, { - "after with subject namespace", - func(filterer SchemaQueryFilterer) SchemaQueryFilterer { + name: "after with subject namespace", + run: func(filterer SchemaQueryFilterer) SchemaQueryFilterer { return filterer.MustFilterWithSubjectsSelectors(datastore.SubjectsSelector{ OptionalSubjectType: "somesubjectype", }).After(toCursor(tuple.MustParse("someresourcetype:foo#viewer@user:bar")), options.ByResource) }, - "SELECT * WHERE (expiration IS NULL OR expiration > NOW()) AND ((subject_ns = ?)) AND (ns,object_id,relation,subject_object_id,subject_relation) > (?,?,?,?,?)", - []any{"somesubjectype", "someresourcetype", "foo", "viewer", "bar", "..."}, - []string{"subject_ns"}, + expectedSQL: "SELECT * WHERE ((subject_ns = ?)) AND (ns,object_id,relation,subject_object_id,subject_relation) > (?,?,?,?,?) AND (expiration IS NULL OR expiration > NOW())", + expectedArgs: []any{"somesubjectype", "someresourcetype", "foo", "viewer", "bar", "..."}, + expectedStaticColumns: []string{"subject_ns"}, }, { - "after with subject namespaces", - func(filterer SchemaQueryFilterer) SchemaQueryFilterer { + name: "after with subject namespaces", + run: func(filterer SchemaQueryFilterer) SchemaQueryFilterer { // NOTE: this isn't really valid (it'll return no results), but is a good test to ensure // the duplicate subject type results in the subject type being in the ORDER BY. return filterer.MustFilterWithSubjectsSelectors(datastore.SubjectsSelector{ @@ -492,66 +526,66 @@ func TestSchemaQueryFilterer(t *testing.T) { OptionalSubjectType: "anothersubjectype", }).After(toCursor(tuple.MustParse("someresourcetype:foo#viewer@user:bar")), options.ByResource) }, - "SELECT * WHERE (expiration IS NULL OR expiration > NOW()) AND ((subject_ns = ?)) AND ((subject_ns = ?)) AND (ns,object_id,relation,subject_ns,subject_object_id,subject_relation) > (?,?,?,?,?,?)", - []any{"somesubjectype", "anothersubjectype", "someresourcetype", "foo", "viewer", "user", "bar", "..."}, - []string{}, + expectedSQL: "SELECT * WHERE ((subject_ns = ?)) AND ((subject_ns = ?)) AND (ns,object_id,relation,subject_ns,subject_object_id,subject_relation) > (?,?,?,?,?,?) AND (expiration IS NULL OR expiration > NOW())", + expectedArgs: []any{"somesubjectype", "anothersubjectype", "someresourcetype", "foo", "viewer", "user", "bar", "..."}, + expectedStaticColumns: []string{}, }, { - "after with resource ID prefix", - func(filterer SchemaQueryFilterer) SchemaQueryFilterer { + name: "after with resource ID prefix", + run: func(filterer SchemaQueryFilterer) SchemaQueryFilterer { return filterer.MustFilterWithResourceIDPrefix("someprefix").After(toCursor(tuple.MustParse("someresourcetype:foo#viewer@user:bar")), options.ByResource) }, - "SELECT * WHERE (expiration IS NULL OR expiration > NOW()) AND object_id LIKE ? AND (ns,object_id,relation,subject_ns,subject_object_id,subject_relation) > (?,?,?,?,?,?)", - []any{"someprefix%", "someresourcetype", "foo", "viewer", "user", "bar", "..."}, - []string{}, + expectedSQL: "SELECT * WHERE object_id LIKE ? AND (ns,object_id,relation,subject_ns,subject_object_id,subject_relation) > (?,?,?,?,?,?) AND (expiration IS NULL OR expiration > NOW())", + expectedArgs: []any{"someprefix%", "someresourcetype", "foo", "viewer", "user", "bar", "..."}, + expectedStaticColumns: []string{}, }, { - "order by subject", - func(filterer SchemaQueryFilterer) SchemaQueryFilterer { + name: "order by subject", + run: func(filterer SchemaQueryFilterer) SchemaQueryFilterer { return filterer.MustFilterWithRelationshipsFilter( datastore.RelationshipsFilter{ OptionalResourceType: "someresourcetype", }, ).TupleOrder(options.BySubject) }, - "SELECT * WHERE (expiration IS NULL OR expiration > NOW()) AND ns = ? ORDER BY subject_ns, subject_object_id, subject_relation, ns, object_id, relation", - []any{"someresourcetype"}, - []string{"ns"}, + expectedSQL: "SELECT * WHERE ns = ? AND (expiration IS NULL OR expiration > NOW()) ORDER BY subject_ns, subject_object_id, subject_relation, ns, object_id, relation", + expectedArgs: []any{"someresourcetype"}, + expectedStaticColumns: []string{"ns"}, }, { - "order by subject, after with subject namespace", - func(filterer SchemaQueryFilterer) SchemaQueryFilterer { + name: "order by subject, after with subject namespace", + run: func(filterer SchemaQueryFilterer) SchemaQueryFilterer { return filterer.MustFilterWithSubjectsSelectors(datastore.SubjectsSelector{ OptionalSubjectType: "somesubjectype", }).After(toCursor(tuple.MustParse("someresourcetype:foo#viewer@user:bar")), options.BySubject) }, - "SELECT * WHERE (expiration IS NULL OR expiration > NOW()) AND ((subject_ns = ?)) AND (subject_object_id,ns,object_id,relation,subject_relation) > (?,?,?,?,?)", - []any{"somesubjectype", "bar", "someresourcetype", "foo", "viewer", "..."}, - []string{"subject_ns"}, + expectedSQL: "SELECT * WHERE ((subject_ns = ?)) AND (subject_object_id,ns,object_id,relation,subject_relation) > (?,?,?,?,?) AND (expiration IS NULL OR expiration > NOW())", + expectedArgs: []any{"somesubjectype", "bar", "someresourcetype", "foo", "viewer", "..."}, + expectedStaticColumns: []string{"subject_ns"}, }, { - "order by subject, after with subject namespace and subject object id", - func(filterer SchemaQueryFilterer) SchemaQueryFilterer { + name: "order by subject, after with subject namespace and subject object id", + run: func(filterer SchemaQueryFilterer) SchemaQueryFilterer { return filterer.MustFilterWithSubjectsSelectors(datastore.SubjectsSelector{ OptionalSubjectType: "somesubjectype", OptionalSubjectIds: []string{"foo"}, }).After(toCursor(tuple.MustParse("someresourcetype:someresource#viewer@user:bar")), options.BySubject) }, - "SELECT * WHERE (expiration IS NULL OR expiration > NOW()) AND ((subject_ns = ? AND subject_object_id IN (?))) AND (ns,object_id,relation,subject_relation) > (?,?,?,?)", - []any{"somesubjectype", "foo", "someresourcetype", "someresource", "viewer", "..."}, - []string{"subject_ns", "subject_object_id"}, + expectedSQL: "SELECT * WHERE ((subject_ns = ? AND subject_object_id IN (?))) AND (ns,object_id,relation,subject_relation) > (?,?,?,?) AND (expiration IS NULL OR expiration > NOW())", + expectedArgs: []any{"somesubjectype", "foo", "someresourcetype", "someresource", "viewer", "..."}, + expectedStaticColumns: []string{"subject_ns", "subject_object_id"}, }, { - "order by subject, after with subject namespace and multiple subject object IDs", - func(filterer SchemaQueryFilterer) SchemaQueryFilterer { + name: "order by subject, after with subject namespace and multiple subject object IDs", + run: func(filterer SchemaQueryFilterer) SchemaQueryFilterer { return filterer.MustFilterWithSubjectsSelectors(datastore.SubjectsSelector{ OptionalSubjectType: "somesubjectype", OptionalSubjectIds: []string{"foo", "bar"}, }).After(toCursor(tuple.MustParse("someresourcetype:someresource#viewer@user:next")), options.BySubject) }, - "SELECT * WHERE (expiration IS NULL OR expiration > NOW()) AND ((subject_ns = ? AND subject_object_id IN (?,?))) AND (subject_object_id,ns,object_id,relation,subject_relation) > (?,?,?,?,?)", - []any{"somesubjectype", "foo", "bar", "next", "someresourcetype", "someresource", "viewer", "..."}, - []string{"subject_ns"}, + expectedSQL: "SELECT * WHERE ((subject_ns = ? AND subject_object_id IN (?,?))) AND (subject_object_id,ns,object_id,relation,subject_relation) > (?,?,?,?,?) AND (expiration IS NULL OR expiration > NOW())", + expectedArgs: []any{"somesubjectype", "foo", "bar", "next", "someresourcetype", "someresource", "viewer", "..."}, + expectedStaticColumns: []string{"subject_ns"}, }, } @@ -586,7 +620,7 @@ func TestSchemaQueryFilterer(t *testing.T) { require.ElementsMatch(t, test.expectedStaticColumns, foundStaticColumns) - ran.queryBuilder = ran.queryBuilder.Columns("*") + ran.queryBuilder = ran.queryBuilderWithExpirationFilter(test.withExpirationDisabled).Columns("*") sql, args, err := ran.queryBuilder.ToSql() require.NoError(t, err) @@ -598,19 +632,21 @@ func TestSchemaQueryFilterer(t *testing.T) { func TestExecuteQuery(t *testing.T) { tcs := []struct { - name string - run func(filterer SchemaQueryFilterer) SchemaQueryFilterer - options []options.QueryOptionsOption - expectedSQL string - expectedArgs []any - expectedSkipCaveats bool + name string + run func(filterer SchemaQueryFilterer) SchemaQueryFilterer + options []options.QueryOptionsOption + expectedSQL string + expectedArgs []any + expectedSkipCaveats bool + expectedSkipExpiration bool + withExpirationDisabled bool }{ { name: "filter by static resource type", run: func(filterer SchemaQueryFilterer) SchemaQueryFilterer { return filterer.FilterToResourceType("sometype") }, - expectedSQL: "SELECT object_id, relation, subject_ns, subject_object_id, subject_relation, caveat, caveat_context, expiration FROM relationtuples WHERE (expiration IS NULL OR expiration > NOW()) AND ns = ?", + expectedSQL: "SELECT object_id, relation, subject_ns, subject_object_id, subject_relation, caveat, caveat_context, expiration FROM relationtuples WHERE ns = ? AND (expiration IS NULL OR expiration > NOW())", expectedArgs: []any{"sometype"}, }, { @@ -618,7 +654,7 @@ func TestExecuteQuery(t *testing.T) { run: func(filterer SchemaQueryFilterer) SchemaQueryFilterer { return filterer.FilterToResourceType("sometype").FilterToResourceID("someobj") }, - expectedSQL: "SELECT relation, subject_ns, subject_object_id, subject_relation, caveat, caveat_context, expiration FROM relationtuples WHERE (expiration IS NULL OR expiration > NOW()) AND ns = ? AND object_id = ?", + expectedSQL: "SELECT relation, subject_ns, subject_object_id, subject_relation, caveat, caveat_context, expiration FROM relationtuples WHERE ns = ? AND object_id = ? AND (expiration IS NULL OR expiration > NOW())", expectedArgs: []any{"sometype", "someobj"}, }, { @@ -626,7 +662,7 @@ func TestExecuteQuery(t *testing.T) { run: func(filterer SchemaQueryFilterer) SchemaQueryFilterer { return filterer.FilterToResourceType("sometype").MustFilterWithResourceIDPrefix("someprefix") }, - expectedSQL: "SELECT object_id, relation, subject_ns, subject_object_id, subject_relation, caveat, caveat_context, expiration FROM relationtuples WHERE (expiration IS NULL OR expiration > NOW()) AND ns = ? AND object_id LIKE ?", + expectedSQL: "SELECT object_id, relation, subject_ns, subject_object_id, subject_relation, caveat, caveat_context, expiration FROM relationtuples WHERE ns = ? AND object_id LIKE ? AND (expiration IS NULL OR expiration > NOW())", expectedArgs: []any{"sometype", "someprefix%"}, }, { @@ -634,7 +670,7 @@ func TestExecuteQuery(t *testing.T) { run: func(filterer SchemaQueryFilterer) SchemaQueryFilterer { return filterer.FilterToResourceType("sometype").MustFilterToResourceIDs([]string{"someobj", "anotherobj"}) }, - expectedSQL: "SELECT object_id, relation, subject_ns, subject_object_id, subject_relation, caveat, caveat_context, expiration FROM relationtuples WHERE (expiration IS NULL OR expiration > NOW()) AND ns = ? AND object_id IN (?,?)", + expectedSQL: "SELECT object_id, relation, subject_ns, subject_object_id, subject_relation, caveat, caveat_context, expiration FROM relationtuples WHERE ns = ? AND object_id IN (?,?) AND (expiration IS NULL OR expiration > NOW())", expectedArgs: []any{"sometype", "someobj", "anotherobj"}, }, { @@ -642,7 +678,7 @@ func TestExecuteQuery(t *testing.T) { run: func(filterer SchemaQueryFilterer) SchemaQueryFilterer { return filterer.FilterToResourceType("sometype").FilterToResourceID("someobj").FilterToRelation("somerel") }, - expectedSQL: "SELECT subject_ns, subject_object_id, subject_relation, caveat, caveat_context, expiration FROM relationtuples WHERE (expiration IS NULL OR expiration > NOW()) AND ns = ? AND object_id = ? AND relation = ?", + expectedSQL: "SELECT subject_ns, subject_object_id, subject_relation, caveat, caveat_context, expiration FROM relationtuples WHERE ns = ? AND object_id = ? AND relation = ? AND (expiration IS NULL OR expiration > NOW())", expectedArgs: []any{"sometype", "someobj", "somerel"}, }, { @@ -652,7 +688,7 @@ func TestExecuteQuery(t *testing.T) { SubjectType: "subns", }) }, - expectedSQL: "SELECT subject_object_id, subject_relation, caveat, caveat_context, expiration FROM relationtuples WHERE (expiration IS NULL OR expiration > NOW()) AND ns = ? AND object_id = ? AND relation = ? AND subject_ns = ?", + expectedSQL: "SELECT subject_object_id, subject_relation, caveat, caveat_context, expiration FROM relationtuples WHERE ns = ? AND object_id = ? AND relation = ? AND subject_ns = ? AND (expiration IS NULL OR expiration > NOW())", expectedArgs: []any{"sometype", "someobj", "somerel", "subns"}, }, { @@ -663,7 +699,7 @@ func TestExecuteQuery(t *testing.T) { OptionalSubjectId: "subid", }) }, - expectedSQL: "SELECT subject_relation, caveat, caveat_context, expiration FROM relationtuples WHERE (expiration IS NULL OR expiration > NOW()) AND ns = ? AND object_id = ? AND relation = ? AND subject_ns = ? AND subject_object_id = ?", + expectedSQL: "SELECT subject_relation, caveat, caveat_context, expiration FROM relationtuples WHERE ns = ? AND object_id = ? AND relation = ? AND subject_ns = ? AND subject_object_id = ? AND (expiration IS NULL OR expiration > NOW())", expectedArgs: []any{"sometype", "someobj", "somerel", "subns", "subid"}, }, { @@ -677,7 +713,7 @@ func TestExecuteQuery(t *testing.T) { }, }) }, - expectedSQL: "SELECT caveat, caveat_context, expiration FROM relationtuples WHERE (expiration IS NULL OR expiration > NOW()) AND ns = ? AND object_id = ? AND relation = ? AND subject_ns = ? AND subject_object_id = ? AND subject_relation = ?", + expectedSQL: "SELECT caveat, caveat_context, expiration FROM relationtuples WHERE ns = ? AND object_id = ? AND relation = ? AND subject_ns = ? AND subject_object_id = ? AND subject_relation = ? AND (expiration IS NULL OR expiration > NOW())", expectedArgs: []any{"sometype", "someobj", "somerel", "subns", "subid", "subrel"}, }, { @@ -695,7 +731,7 @@ func TestExecuteQuery(t *testing.T) { options.WithSkipCaveats(true), }, expectedSkipCaveats: true, - expectedSQL: "SELECT expiration FROM relationtuples WHERE (expiration IS NULL OR expiration > NOW()) AND ns = ? AND object_id = ? AND relation = ? AND subject_ns = ? AND subject_object_id = ? AND subject_relation = ?", + expectedSQL: "SELECT expiration FROM relationtuples WHERE ns = ? AND object_id = ? AND relation = ? AND subject_ns = ? AND subject_object_id = ? AND subject_relation = ? AND (expiration IS NULL OR expiration > NOW())", expectedArgs: []any{"sometype", "someobj", "somerel", "subns", "subid", "subrel"}, }, { @@ -713,7 +749,7 @@ func TestExecuteQuery(t *testing.T) { options.WithSkipCaveats(true), }, expectedSkipCaveats: true, - expectedSQL: "SELECT object_id, expiration FROM relationtuples WHERE (expiration IS NULL OR expiration > NOW()) AND ns = ? AND object_id IN (?,?) AND relation = ? AND subject_ns = ? AND subject_object_id = ? AND subject_relation = ?", + expectedSQL: "SELECT object_id, expiration FROM relationtuples WHERE ns = ? AND object_id IN (?,?) AND relation = ? AND subject_ns = ? AND subject_object_id = ? AND subject_relation = ? AND (expiration IS NULL OR expiration > NOW())", expectedArgs: []any{"sometype", "someobj", "anotherobj", "somerel", "subns", "subid", "subrel"}, }, { @@ -725,7 +761,7 @@ func TestExecuteQuery(t *testing.T) { options.WithSkipCaveats(true), }, expectedSkipCaveats: true, - expectedSQL: "SELECT object_id, relation, subject_ns, subject_object_id, subject_relation, expiration FROM relationtuples WHERE (expiration IS NULL OR expiration > NOW()) AND ns = ?", + expectedSQL: "SELECT object_id, relation, subject_ns, subject_object_id, subject_relation, expiration FROM relationtuples WHERE ns = ? AND (expiration IS NULL OR expiration > NOW())", expectedArgs: []any{"sometype"}, }, { @@ -735,7 +771,7 @@ func TestExecuteQuery(t *testing.T) { SubjectType: "subns", }) }, - expectedSQL: "SELECT ns, object_id, relation, subject_object_id, subject_relation, caveat, caveat_context, expiration FROM relationtuples WHERE (expiration IS NULL OR expiration > NOW()) AND subject_ns = ?", + expectedSQL: "SELECT ns, object_id, relation, subject_object_id, subject_relation, caveat, caveat_context, expiration FROM relationtuples WHERE subject_ns = ? AND (expiration IS NULL OR expiration > NOW())", expectedArgs: []any{"subns"}, }, { @@ -746,7 +782,7 @@ func TestExecuteQuery(t *testing.T) { OptionalSubjectId: "subid", }) }, - expectedSQL: "SELECT ns, object_id, relation, subject_relation, caveat, caveat_context, expiration FROM relationtuples WHERE (expiration IS NULL OR expiration > NOW()) AND subject_ns = ? AND subject_object_id = ?", + expectedSQL: "SELECT ns, object_id, relation, subject_relation, caveat, caveat_context, expiration FROM relationtuples WHERE subject_ns = ? AND subject_object_id = ? AND (expiration IS NULL OR expiration > NOW())", expectedArgs: []any{"subns", "subid"}, }, { @@ -759,7 +795,7 @@ func TestExecuteQuery(t *testing.T) { }, }) }, - expectedSQL: "SELECT ns, object_id, relation, subject_object_id, caveat, caveat_context, expiration FROM relationtuples WHERE (expiration IS NULL OR expiration > NOW()) AND subject_ns = ? AND subject_relation = ?", + expectedSQL: "SELECT ns, object_id, relation, subject_object_id, caveat, caveat_context, expiration FROM relationtuples WHERE subject_ns = ? AND subject_relation = ? AND (expiration IS NULL OR expiration > NOW())", expectedArgs: []any{"subns", "subrel"}, }, { @@ -773,7 +809,7 @@ func TestExecuteQuery(t *testing.T) { }, }) }, - expectedSQL: "SELECT ns, object_id, relation, caveat, caveat_context, expiration FROM relationtuples WHERE (expiration IS NULL OR expiration > NOW()) AND subject_ns = ? AND subject_object_id = ? AND subject_relation = ?", + expectedSQL: "SELECT ns, object_id, relation, caveat, caveat_context, expiration FROM relationtuples WHERE subject_ns = ? AND subject_object_id = ? AND subject_relation = ? AND (expiration IS NULL OR expiration > NOW())", expectedArgs: []any{"subns", "subid", "subrel"}, }, { @@ -787,7 +823,7 @@ func TestExecuteQuery(t *testing.T) { OptionalSubjectId: "subid", }) }, - expectedSQL: "SELECT ns, object_id, relation, subject_ns, subject_relation, caveat, caveat_context, expiration FROM relationtuples WHERE (expiration IS NULL OR expiration > NOW()) AND subject_ns = ? AND subject_object_id = ? AND subject_ns = ? AND subject_object_id = ?", + expectedSQL: "SELECT ns, object_id, relation, subject_ns, subject_relation, caveat, caveat_context, expiration FROM relationtuples WHERE subject_ns = ? AND subject_object_id = ? AND subject_ns = ? AND subject_object_id = ? AND (expiration IS NULL OR expiration > NOW())", expectedArgs: []any{"subns", "subid", "anothersubns", "subid"}, }, { @@ -799,7 +835,7 @@ func TestExecuteQuery(t *testing.T) { OptionalSubjectType: "anothersubjectype", }) }, - expectedSQL: "SELECT ns, object_id, relation, subject_ns, subject_object_id, subject_relation, caveat, caveat_context, expiration FROM relationtuples WHERE (expiration IS NULL OR expiration > NOW()) AND ((subject_ns = ?) OR (subject_ns = ?))", + expectedSQL: "SELECT ns, object_id, relation, subject_ns, subject_object_id, subject_relation, caveat, caveat_context, expiration FROM relationtuples WHERE ((subject_ns = ?) OR (subject_ns = ?)) AND (expiration IS NULL OR expiration > NOW())", expectedArgs: []any{"somesubjectype", "anothersubjectype"}, }, { @@ -811,9 +847,44 @@ func TestExecuteQuery(t *testing.T) { OptionalSubjectType: "anothersubjectype", }).FilterToResourceType("sometype") }, - expectedSQL: "SELECT object_id, relation, subject_ns, subject_object_id, subject_relation, caveat, caveat_context, expiration FROM relationtuples WHERE (expiration IS NULL OR expiration > NOW()) AND ((subject_ns = ?) OR (subject_ns = ?)) AND ns = ?", + expectedSQL: "SELECT object_id, relation, subject_ns, subject_object_id, subject_relation, caveat, caveat_context, expiration FROM relationtuples WHERE ((subject_ns = ?) OR (subject_ns = ?)) AND ns = ? AND (expiration IS NULL OR expiration > NOW())", expectedArgs: []any{"somesubjectype", "anothersubjectype", "sometype"}, }, + { + name: "filter by static resource type with expiration disabled", + run: func(filterer SchemaQueryFilterer) SchemaQueryFilterer { + return filterer.FilterToResourceType("sometype") + }, + expectedSQL: "SELECT object_id, relation, subject_ns, subject_object_id, subject_relation, caveat, caveat_context FROM relationtuples WHERE ns = ?", + expectedArgs: []any{"sometype"}, + withExpirationDisabled: true, + }, + { + name: "filter by static resource type with expiration skipped", + run: func(filterer SchemaQueryFilterer) SchemaQueryFilterer { + return filterer.FilterToResourceType("sometype") + }, + expectedSQL: "SELECT object_id, relation, subject_ns, subject_object_id, subject_relation, caveat, caveat_context FROM relationtuples WHERE ns = ?", + expectedArgs: []any{"sometype"}, + withExpirationDisabled: false, + expectedSkipExpiration: true, + options: []options.QueryOptionsOption{ + options.WithSkipExpiration(true), + }, + }, + { + name: "filter by static resource type with expiration skipped and disabled", + run: func(filterer SchemaQueryFilterer) SchemaQueryFilterer { + return filterer.FilterToResourceType("sometype") + }, + expectedSQL: "SELECT object_id, relation, subject_ns, subject_object_id, subject_relation, caveat, caveat_context FROM relationtuples WHERE ns = ?", + expectedArgs: []any{"sometype"}, + withExpirationDisabled: true, + expectedSkipExpiration: true, + options: []options.QueryOptionsOption{ + options.WithSkipExpiration(true), + }, + }, } for _, tc := range tcs { @@ -833,6 +904,7 @@ func TestExecuteQuery(t *testing.T) { WithPaginationFilterType(TupleComparison), WithColumnOptimization(ColumnOptimizationOptionStaticValues), WithNowFunction("NOW"), + WithExpirationDisabled(tc.withExpirationDisabled), ) filterer := NewSchemaQueryFiltererForRelationshipsSelect(*schema, 100) ran := tc.run(filterer) @@ -847,6 +919,7 @@ func TestExecuteQuery(t *testing.T) { require.Equal(t, tc.expectedSQL, sql) require.Equal(t, tc.expectedArgs, args) require.Equal(t, tc.expectedSkipCaveats, builder.SkipCaveats) + require.Equal(t, tc.expectedSkipExpiration, builder.SkipExpiration) return nil, nil }, } diff --git a/internal/datastore/crdb/crdb.go b/internal/datastore/crdb/crdb.go index ced1386356..4165473ebf 100644 --- a/internal/datastore/crdb/crdb.go +++ b/internal/datastore/crdb/crdb.go @@ -223,6 +223,7 @@ func newCRDBDatastore(ctx context.Context, url string, options ...Option) (datas common.WithNowFunction("NOW"), common.WithColumnOptimization(config.columnOptimizationOption), common.WithWithIntegrityColumns(config.withIntegrity), + common.WithExpirationDisabled(config.expirationDisabled), ) ds := &crdbDatastore{ diff --git a/internal/datastore/crdb/options.go b/internal/datastore/crdb/options.go index 7424463246..10ff645aab 100644 --- a/internal/datastore/crdb/options.go +++ b/internal/datastore/crdb/options.go @@ -30,6 +30,7 @@ type crdbOptions struct { withIntegrity bool allowedMigrations []string columnOptimizationOption common.ColumnOptimizationOption + expirationDisabled bool } const ( @@ -58,6 +59,7 @@ const ( defaultFilterMaximumIDCount = 100 defaultWithIntegrity = false defaultColumnOptimizationOption = common.ColumnOptimizationOptionNone + defaultExpirationDisabled = false ) // Option provides the facility to configure how clients within the CRDB @@ -82,6 +84,7 @@ func generateConfig(options []Option) (crdbOptions, error) { filterMaximumIDCount: defaultFilterMaximumIDCount, withIntegrity: defaultWithIntegrity, columnOptimizationOption: defaultColumnOptimizationOption, + expirationDisabled: defaultExpirationDisabled, } for _, option := range options { @@ -360,3 +363,8 @@ func WithColumnOptimization(isEnabled bool) Option { } } } + +// WithExpirationDisabled configures the datastore to disable relationship expiration. +func WithExpirationDisabled(isDisabled bool) Option { + return func(po *crdbOptions) { po.expirationDisabled = isDisabled } +} diff --git a/internal/datastore/memdb/readonly.go b/internal/datastore/memdb/readonly.go index 7878b3c276..e348e2d773 100644 --- a/internal/datastore/memdb/readonly.go +++ b/internal/datastore/memdb/readonly.go @@ -151,11 +151,11 @@ func (r *memdbReader) QueryRelationships( fallthrough case options.ByResource: - iter := newMemdbTupleIterator(r.now, filteredIterator, queryOpts.Limit, queryOpts.SkipCaveats) + iter := newMemdbTupleIterator(r.now, filteredIterator, queryOpts.Limit, queryOpts.SkipCaveats, queryOpts.SkipExpiration) return iter, nil case options.BySubject: - return newSubjectSortedIterator(r.now, filteredIterator, queryOpts.Limit, queryOpts.SkipCaveats) + return newSubjectSortedIterator(r.now, filteredIterator, queryOpts.Limit, queryOpts.SkipCaveats, queryOpts.SkipExpiration) default: return nil, spiceerrors.MustBugf("unsupported sort order: %v", queryOpts.Sort) @@ -214,11 +214,11 @@ func (r *memdbReader) ReverseQueryRelationships( fallthrough case options.ByResource: - iter := newMemdbTupleIterator(r.now, filteredIterator, queryOpts.LimitForReverse, false) + iter := newMemdbTupleIterator(r.now, filteredIterator, queryOpts.LimitForReverse, false, false) return iter, nil case options.BySubject: - return newSubjectSortedIterator(r.now, filteredIterator, queryOpts.LimitForReverse, false) + return newSubjectSortedIterator(r.now, filteredIterator, queryOpts.LimitForReverse, false, false) default: return nil, spiceerrors.MustBugf("unsupported sort order: %v", queryOpts.SortForReverse) @@ -476,7 +476,7 @@ func makeCursorFilterFn(after options.Cursor, order options.SortOrder) func(tpl return noopCursorFilter } -func newSubjectSortedIterator(now time.Time, it memdb.ResultIterator, limit *uint64, skipCaveats bool) (datastore.RelationshipIterator, error) { +func newSubjectSortedIterator(now time.Time, it memdb.ResultIterator, limit *uint64, skipCaveats bool, skipExpiration bool) (datastore.RelationshipIterator, error) { results := make([]tuple.Relationship, 0) // Coalesce all of the results into memory @@ -494,6 +494,10 @@ func newSubjectSortedIterator(now time.Time, it memdb.ResultIterator, limit *uin return nil, spiceerrors.MustBugf("unexpected caveat in result for relationship: %v", rt) } + if skipExpiration && rt.OptionalExpiration != nil { + return nil, spiceerrors.MustBugf("unexpected expiration in result for relationship: %v", rt) + } + results = append(results, rt) } @@ -530,7 +534,7 @@ func eq(lhsNamespace, lhsObjectID, lhsRelation string, rhs tuple.ObjectAndRelati return lhsNamespace == rhs.ObjectType && lhsObjectID == rhs.ObjectID && lhsRelation == rhs.Relation } -func newMemdbTupleIterator(now time.Time, it memdb.ResultIterator, limit *uint64, skipCaveats bool) datastore.RelationshipIterator { +func newMemdbTupleIterator(now time.Time, it memdb.ResultIterator, limit *uint64, skipCaveats bool, skipExpiration bool) datastore.RelationshipIterator { var count uint64 return func(yield func(tuple.Relationship, error) bool) { for { @@ -551,15 +555,20 @@ func newMemdbTupleIterator(now time.Time, it memdb.ResultIterator, limit *uint64 continue } - if rt.OptionalExpiration != nil && rt.OptionalExpiration.Before(now) { - continue - } - if skipCaveats && rt.OptionalCaveat != nil { yield(rt, fmt.Errorf("unexpected caveat in result for relationship: %v", rt)) return } + if skipExpiration && rt.OptionalExpiration != nil { + yield(rt, fmt.Errorf("unexpected expiration in result for relationship: %v", rt)) + return + } + + if rt.OptionalExpiration != nil && rt.OptionalExpiration.Before(now) { + continue + } + if !yield(rt, err) { return } diff --git a/internal/datastore/mysql/datastore.go b/internal/datastore/mysql/datastore.go index ea39afd629..1462e6fbf6 100644 --- a/internal/datastore/mysql/datastore.go +++ b/internal/datastore/mysql/datastore.go @@ -259,6 +259,7 @@ func newMySQLDatastore(ctx context.Context, uri string, replicaIndex int, option common.WithPlaceholderFormat(sq.Question), common.WithNowFunction("NOW"), common.WithColumnOptimization(config.columnOptimizationOption), + common.WithExpirationDisabled(config.expirationDisabled), ) store := &Datastore{ diff --git a/internal/datastore/mysql/gc.go b/internal/datastore/mysql/gc.go index d4375eddbd..9558a93a34 100644 --- a/internal/datastore/mysql/gc.go +++ b/internal/datastore/mysql/gc.go @@ -108,6 +108,10 @@ func (mds *Datastore) DeleteBeforeTx( } func (mds *Datastore) DeleteExpiredRels(ctx context.Context) (int64, error) { + if mds.schema.ExpirationDisabled { + return 0, nil + } + now, err := mds.Now(ctx) if err != nil { return 0, err diff --git a/internal/datastore/mysql/options.go b/internal/datastore/mysql/options.go index 713e97e671..4405dbcaba 100644 --- a/internal/datastore/mysql/options.go +++ b/internal/datastore/mysql/options.go @@ -27,6 +27,7 @@ const ( defaultCredentialsProviderName = "" defaultFilterMaximumIDCount = 100 defaultColumnOptimizationOption = common.ColumnOptimizationOptionNone + defaultExpirationDisabled = false ) type mysqlOptions struct { @@ -50,6 +51,7 @@ type mysqlOptions struct { filterMaximumIDCount uint16 allowedMigrations []string columnOptimizationOption common.ColumnOptimizationOption + expirationDisabled bool } // Option provides the facility to configure how clients within the @@ -74,6 +76,7 @@ func generateConfig(options []Option) (mysqlOptions, error) { credentialsProviderName: defaultCredentialsProviderName, filterMaximumIDCount: defaultFilterMaximumIDCount, columnOptimizationOption: defaultColumnOptimizationOption, + expirationDisabled: defaultExpirationDisabled, } for _, option := range options { @@ -284,3 +287,10 @@ func WithColumnOptimization(isEnabled bool) Option { } } } + +// WithExpirationDisabled disables the expiration of relationships in the MySQL datastore. +func WithExpirationDisabled(isDisabled bool) Option { + return func(mo *mysqlOptions) { + mo.expirationDisabled = isDisabled + } +} diff --git a/internal/datastore/postgres/gc.go b/internal/datastore/postgres/gc.go index 674082171d..a7419f70d8 100644 --- a/internal/datastore/postgres/gc.go +++ b/internal/datastore/postgres/gc.go @@ -77,6 +77,10 @@ func (pgd *pgDatastore) TxIDBefore(ctx context.Context, before time.Time) (datas } func (pgd *pgDatastore) DeleteExpiredRels(ctx context.Context) (int64, error) { + if pgd.schema.ExpirationDisabled { + return 0, nil + } + now, err := pgd.Now(ctx) if err != nil { return 0, err diff --git a/internal/datastore/postgres/options.go b/internal/datastore/postgres/options.go index f0cef1bd0f..1a2c1527ec 100644 --- a/internal/datastore/postgres/options.go +++ b/internal/datastore/postgres/options.go @@ -29,6 +29,7 @@ type postgresOptions struct { analyzeBeforeStatistics bool gcEnabled bool readStrictMode bool + expirationDisabled bool columnOptimizationOption common.ColumnOptimizationOption migrationPhase string @@ -70,6 +71,7 @@ const ( defaultReadStrictMode = false defaultFilterMaximumIDCount = 100 defaultColumnOptimizationOption = common.ColumnOptimizationOptionNone + defaultExpirationDisabled = false ) // Option provides the facility to configure how clients within the @@ -93,6 +95,7 @@ func generateConfig(options []Option) (postgresOptions, error) { queryInterceptor: nil, filterMaximumIDCount: defaultFilterMaximumIDCount, columnOptimizationOption: defaultColumnOptimizationOption, + expirationDisabled: defaultExpirationDisabled, } for _, option := range options { @@ -392,3 +395,8 @@ func WithColumnOptimization(isEnabled bool) Option { } } } + +// WithExpirationDisabled disables support for relationship expiration. +func WithExpirationDisabled(isDisabled bool) Option { + return func(po *postgresOptions) { po.expirationDisabled = isDisabled } +} diff --git a/internal/datastore/postgres/postgres.go b/internal/datastore/postgres/postgres.go index f69fd2d8cd..0c700f562d 100644 --- a/internal/datastore/postgres/postgres.go +++ b/internal/datastore/postgres/postgres.go @@ -330,6 +330,7 @@ func newPostgresDatastore( common.WithPlaceholderFormat(sq.Dollar), common.WithNowFunction("NOW"), common.WithColumnOptimization(config.columnOptimizationOption), + common.WithExpirationDisabled(config.expirationDisabled), ) datastore := &pgDatastore{ diff --git a/internal/datastore/spanner/options.go b/internal/datastore/spanner/options.go index a0ae438a23..75f84cf4b9 100644 --- a/internal/datastore/spanner/options.go +++ b/internal/datastore/spanner/options.go @@ -28,6 +28,7 @@ type spannerOptions struct { allowedMigrations []string filterMaximumIDCount uint16 columnOptimizationOption common.ColumnOptimizationOption + expirationDisabled bool } type migrationPhase uint8 @@ -52,6 +53,7 @@ const ( maxRevisionQuantization = 24 * time.Hour defaultFilterMaximumIDCount = 100 defaultColumnOptimizationOption = common.ColumnOptimizationOptionNone + defaultExpirationDisabled = false ) // Option provides the facility to configure how clients within the Spanner @@ -76,6 +78,7 @@ func generateConfig(options []Option) (spannerOptions, error) { migrationPhase: "", // no migration filterMaximumIDCount: defaultFilterMaximumIDCount, columnOptimizationOption: defaultColumnOptimizationOption, + expirationDisabled: defaultExpirationDisabled, } for _, option := range options { @@ -240,3 +243,10 @@ func WithColumnOptimization(isEnabled bool) Option { } } } + +// WithExpirationDisabled disables relationship expiration support in the Spanner. +func WithExpirationDisabled(isDisabled bool) Option { + return func(po *spannerOptions) { + po.expirationDisabled = isDisabled + } +} diff --git a/internal/graph/check.go b/internal/graph/check.go index 8cb3b62692..fbf2d41207 100644 --- a/internal/graph/check.go +++ b/internal/graph/check.go @@ -311,8 +311,12 @@ func (cc *ConcurrentChecker) checkDirect(ctx context.Context, crc currentRequest hasNonTerminals := false hasDirectSubject := false hasWildcardSubject := false + directSubjectOrWildcardCanHaveCaveats := false + directSubjectOrWildcardCanHaveExpiration := false + nonTerminalsCanHaveCaveats := false + nonTerminalsCanHaveExpiration := false defer func() { if hasNonTerminals { @@ -341,6 +345,10 @@ func (cc *ConcurrentChecker) checkDirect(ctx context.Context, crc currentRequest if allowedDirectRelation.RequiredCaveat != nil { directSubjectOrWildcardCanHaveCaveats = true } + + if allowedDirectRelation.RequiredExpiration != nil { + directSubjectOrWildcardCanHaveExpiration = true + } } // If the relation found is not an ellipsis, then this is a nested relation that @@ -353,6 +361,9 @@ func (cc *ConcurrentChecker) checkDirect(ctx context.Context, crc currentRequest if allowedDirectRelation.RequiredCaveat != nil { nonTerminalsCanHaveCaveats = true } + if allowedDirectRelation.RequiredExpiration != nil { + nonTerminalsCanHaveExpiration = true + } } } @@ -391,7 +402,10 @@ func (cc *ConcurrentChecker) checkDirect(ctx context.Context, crc currentRequest OptionalSubjectsSelectors: subjectSelectors, } - it, err := ds.QueryRelationships(ctx, filter, options.WithSkipCaveats(!directSubjectOrWildcardCanHaveCaveats)) + it, err := ds.QueryRelationships(ctx, filter, + options.WithSkipCaveats(!directSubjectOrWildcardCanHaveCaveats), + options.WithSkipExpiration(!directSubjectOrWildcardCanHaveExpiration), + ) if err != nil { return checkResultError(NewCheckFailureErr(err), emptyMetadata) } @@ -440,7 +454,10 @@ func (cc *ConcurrentChecker) checkDirect(ctx context.Context, crc currentRequest }, } - it, err := ds.QueryRelationships(ctx, filter, options.WithSkipCaveats(!nonTerminalsCanHaveCaveats)) + it, err := ds.QueryRelationships(ctx, filter, + options.WithSkipCaveats(!nonTerminalsCanHaveCaveats), + options.WithSkipExpiration(!nonTerminalsCanHaveExpiration), + ) if err != nil { return checkResultError(NewCheckFailureErr(err), emptyMetadata) } @@ -634,6 +651,56 @@ func (cc *ConcurrentChecker) checkComputedUserset(ctx context.Context, crc curre return combineResultWithFoundResources(result, membershipSet) } +// queryOptionsForArrowRelation returns query options such as SkipCaveats and SkipExpiration if *none* of the subject +// types of the given relation support caveats or expiration. +func (cc *ConcurrentChecker) queryOptionsForArrowRelation(ctx context.Context, reader datastore.Reader, namespaceName string, relationName string) ([]options.QueryOptionsOption, error) { + // TODO(jschorr): Change to use the type system once we wire it through Check dispatch. + nsDefs, err := reader.LookupNamespacesWithNames(ctx, []string{namespaceName}) + if err != nil { + return nil, err + } + + if len(nsDefs) != 1 { + return nil, nil + } + + var relation *core.Relation + for _, rel := range nsDefs[0].Definition.Relation { + if rel.Name == relationName { + relation = rel + break + } + } + + if relation == nil || relation.TypeInformation == nil { + return nil, nil + } + + hasCaveats := false + hasExpiration := false + + for _, allowedDirectRelation := range relation.TypeInformation.GetAllowedDirectRelations() { + if allowedDirectRelation.RequiredCaveat != nil { + hasCaveats = true + } + + if allowedDirectRelation.RequiredExpiration != nil { + hasExpiration = true + } + } + + opts := make([]options.QueryOptionsOption, 0, 2) + if !hasCaveats { + opts = append(opts, options.WithSkipCaveats(true)) + } + + if !hasExpiration { + opts = append(opts, options.WithSkipExpiration(true)) + } + + return opts, nil +} + func filterForFoundMemberResource(resourceRelation *core.RelationReference, resourceIds []string, subject *core.ObjectAndRelation) (*MembershipSet, []string) { if resourceRelation.Namespace != subject.Namespace || resourceRelation.Relation != subject.Relation { return nil, resourceIds @@ -684,11 +751,16 @@ func checkIntersectionTupleToUserset( // Query for the subjects over which to walk the TTU. log.Ctx(ctx).Trace().Object("intersectionttu", crc.parentReq).Send() ds := datastoremw.MustFromContext(ctx).SnapshotReader(crc.parentReq.Revision) + queryOpts, err := cc.queryOptionsForArrowRelation(ctx, ds, crc.parentReq.ResourceRelation.Namespace, ttu.GetTupleset().GetRelation()) + if err != nil { + return checkResultError(NewCheckFailureErr(err), emptyMetadata) + } + it, err := ds.QueryRelationships(ctx, datastore.RelationshipsFilter{ OptionalResourceType: crc.parentReq.ResourceRelation.Namespace, OptionalResourceIds: crc.filteredResourceIDs, OptionalResourceRelation: ttu.GetTupleset().GetRelation(), - }) + }, queryOpts...) if err != nil { return checkResultError(NewCheckFailureErr(err), emptyMetadata) } @@ -845,11 +917,17 @@ func checkTupleToUserset[T relation]( log.Ctx(ctx).Trace().Object("ttu", crc.parentReq).Send() ds := datastoremw.MustFromContext(ctx).SnapshotReader(crc.parentReq.Revision) + + queryOpts, err := cc.queryOptionsForArrowRelation(ctx, ds, crc.parentReq.ResourceRelation.Namespace, ttu.GetTupleset().GetRelation()) + if err != nil { + return checkResultError(NewCheckFailureErr(err), emptyMetadata) + } + it, err := ds.QueryRelationships(ctx, datastore.RelationshipsFilter{ OptionalResourceType: crc.parentReq.ResourceRelation.Namespace, OptionalResourceIds: filteredResourceIDs, OptionalResourceRelation: ttu.GetTupleset().GetRelation(), - }) + }, queryOpts...) if err != nil { return checkResultError(NewCheckFailureErr(err), emptyMetadata) } diff --git a/internal/services/v1/experimental_test.go b/internal/services/v1/experimental_test.go index 595878c3ea..dd84ebda37 100644 --- a/internal/services/v1/experimental_test.go +++ b/internal/services/v1/experimental_test.go @@ -10,7 +10,6 @@ import ( "strconv" "testing" - "github.com/authzed/authzed-go/pkg/responsemeta" v1 "github.com/authzed/authzed-go/proto/authzed/api/v1" "github.com/authzed/grpcutil" "github.com/ccoveille/go-safecast" @@ -435,10 +434,9 @@ func TestBulkCheckPermission(t *testing.T) { defer cleanup() testCases := []struct { - name string - requests []string - response []bulkCheckTest - expectedDispatchCount int + name string + requests []string + response []bulkCheckTest }{ { name: "same resource and permission, different subjects", @@ -461,7 +459,6 @@ func TestBulkCheckPermission(t *testing.T) { resp: v1.CheckPermissionResponse_PERMISSIONSHIP_NO_PERMISSION, }, }, - expectedDispatchCount: 49, }, { name: "different resources, same permission and subject", @@ -484,7 +481,6 @@ func TestBulkCheckPermission(t *testing.T) { resp: v1.CheckPermissionResponse_PERMISSIONSHIP_NO_PERMISSION, }, }, - expectedDispatchCount: 18, }, { name: "some items fail", @@ -507,36 +503,6 @@ func TestBulkCheckPermission(t *testing.T) { err: namespace.NewNamespaceNotFoundErr("superfake"), }, }, - expectedDispatchCount: 17, - }, - { - name: "different caveat context is not clustered", - requests: []string{ - `document:masterplan#view@user:eng_lead[test:{"secret": "1234"}]`, - `document:companyplan#view@user:eng_lead[test:{"secret": "1234"}]`, - `document:masterplan#view@user:eng_lead[test:{"secret": "4321"}]`, - `document:masterplan#view@user:eng_lead`, - }, - response: []bulkCheckTest{ - { - req: `document:masterplan#view@user:eng_lead[test:{"secret": "1234"}]`, - resp: v1.CheckPermissionResponse_PERMISSIONSHIP_HAS_PERMISSION, - }, - { - req: `document:companyplan#view@user:eng_lead[test:{"secret": "1234"}]`, - resp: v1.CheckPermissionResponse_PERMISSIONSHIP_NO_PERMISSION, - }, - { - req: `document:masterplan#view@user:eng_lead[test:{"secret": "4321"}]`, - resp: v1.CheckPermissionResponse_PERMISSIONSHIP_NO_PERMISSION, - }, - { - req: `document:masterplan#view@user:eng_lead`, - resp: v1.CheckPermissionResponse_PERMISSIONSHIP_CONDITIONAL_PERMISSION, - partial: []string{"secret"}, - }, - }, - expectedDispatchCount: 50, }, { name: "namespace validation", @@ -554,7 +520,6 @@ func TestBulkCheckPermission(t *testing.T) { err: namespace.NewNamespaceNotFoundErr("fake"), }, }, - expectedDispatchCount: 1, }, { name: "chunking test", @@ -577,7 +542,6 @@ func TestBulkCheckPermission(t *testing.T) { return toReturn })(), - expectedDispatchCount: 11, }, { name: "chunking test with errors", @@ -607,7 +571,6 @@ func TestBulkCheckPermission(t *testing.T) { return toReturn })(), - expectedDispatchCount: 11, }, { name: "same resource and permission with same subject, repeated", @@ -625,7 +588,6 @@ func TestBulkCheckPermission(t *testing.T) { resp: v1.CheckPermissionResponse_PERMISSIONSHIP_HAS_PERMISSION, }, }, - expectedDispatchCount: 17, }, } @@ -694,10 +656,6 @@ func TestBulkCheckPermission(t *testing.T) { actual, err := client.BulkCheckPermission(context.Background(), &req, grpc.Trailer(&trailer)) require.NoError(t, err) - dispatchCount, err := responsemeta.GetIntResponseTrailerMetadata(trailer, responsemeta.DispatchedOperationsCount) - require.NoError(t, err) - require.Equal(t, tt.expectedDispatchCount, dispatchCount) - testutil.RequireProtoSlicesEqual(t, expected, actual.Pairs, nil, "response bulk check pairs did not match") }) } diff --git a/internal/services/v1/permissions_test.go b/internal/services/v1/permissions_test.go index 5a72b2f6a9..96bfcfc74d 100644 --- a/internal/services/v1/permissions_test.go +++ b/internal/services/v1/permissions_test.go @@ -1027,9 +1027,9 @@ func TestCheckWithCaveats(t *testing.T) { AtLeastAsFresh: zedtoken.MustNewFromRevision(revision), }, }, - Resource: obj("document", "companyplan"), - Permission: "view", - Subject: sub("user", "owner", ""), + Resource: obj("document", "caveatedplan"), + Permission: "caveated_viewer", + Subject: sub("user", "caveatedguy", ""), } // caveat evaluated and returned false @@ -1774,10 +1774,9 @@ func TestCheckBulkPermissions(t *testing.T) { defer cleanup() testCases := []struct { - name string - requests []string - response []bulkCheckTest - expectedDispatchCount int + name string + requests []string + response []bulkCheckTest }{ { name: "same resource and permission, different subjects", @@ -1800,7 +1799,6 @@ func TestCheckBulkPermissions(t *testing.T) { resp: v1.CheckPermissionResponse_PERMISSIONSHIP_NO_PERMISSION, }, }, - expectedDispatchCount: 49, }, { name: "different resources, same permission and subject", @@ -1823,7 +1821,6 @@ func TestCheckBulkPermissions(t *testing.T) { resp: v1.CheckPermissionResponse_PERMISSIONSHIP_NO_PERMISSION, }, }, - expectedDispatchCount: 18, }, { name: "some items fail", @@ -1846,36 +1843,29 @@ func TestCheckBulkPermissions(t *testing.T) { err: namespace.NewNamespaceNotFoundErr("superfake"), }, }, - expectedDispatchCount: 17, }, { name: "different caveat context is not clustered", requests: []string{ - `document:masterplan#view@user:eng_lead[test:{"secret": "1234"}]`, - `document:companyplan#view@user:eng_lead[test:{"secret": "1234"}]`, - `document:masterplan#view@user:eng_lead[test:{"secret": "4321"}]`, - `document:masterplan#view@user:eng_lead`, + `document:caveatedplan#caveated_viewer@user:caveatedguy[test:{"secret": "1234"}]`, + `document:caveatedplan#caveated_viewer@user:caveatedguy[test:{"secret": "4321"}]`, + `document:caveatedplan#caveated_viewer@user:caveatedguy`, }, response: []bulkCheckTest{ { - req: `document:masterplan#view@user:eng_lead[test:{"secret": "1234"}]`, + req: `document:caveatedplan#caveated_viewer@user:caveatedguy[test:{"secret": "1234"}]`, resp: v1.CheckPermissionResponse_PERMISSIONSHIP_HAS_PERMISSION, }, { - req: `document:companyplan#view@user:eng_lead[test:{"secret": "1234"}]`, - resp: v1.CheckPermissionResponse_PERMISSIONSHIP_NO_PERMISSION, - }, - { - req: `document:masterplan#view@user:eng_lead[test:{"secret": "4321"}]`, + req: `document:caveatedplan#caveated_viewer@user:caveatedguy[test:{"secret": "4321"}]`, resp: v1.CheckPermissionResponse_PERMISSIONSHIP_NO_PERMISSION, }, { - req: `document:masterplan#view@user:eng_lead`, + req: `document:caveatedplan#caveated_viewer@user:caveatedguy`, resp: v1.CheckPermissionResponse_PERMISSIONSHIP_CONDITIONAL_PERMISSION, partial: []string{"secret"}, }, }, - expectedDispatchCount: 50, }, { name: "namespace validation", @@ -1893,7 +1883,6 @@ func TestCheckBulkPermissions(t *testing.T) { err: namespace.NewNamespaceNotFoundErr("fake"), }, }, - expectedDispatchCount: 1, }, { name: "chunking test", @@ -1916,7 +1905,6 @@ func TestCheckBulkPermissions(t *testing.T) { return toReturn })(), - expectedDispatchCount: 11, }, { name: "chunking test with errors", @@ -1946,7 +1934,6 @@ func TestCheckBulkPermissions(t *testing.T) { return toReturn })(), - expectedDispatchCount: 11, }, { name: "same resource and permission with same subject, repeated", @@ -1964,7 +1951,6 @@ func TestCheckBulkPermissions(t *testing.T) { resp: v1.CheckPermissionResponse_PERMISSIONSHIP_HAS_PERMISSION, }, }, - expectedDispatchCount: 17, }, } @@ -2024,10 +2010,6 @@ func TestCheckBulkPermissions(t *testing.T) { actual, err := client.CheckBulkPermissions(context.Background(), &req, grpc.Trailer(&trailer)) require.NoError(t, err) - dispatchCount, err := responsemeta.GetIntResponseTrailerMetadata(trailer, responsemeta.DispatchedOperationsCount) - require.NoError(t, err) - require.Equal(t, tt.expectedDispatchCount, dispatchCount) - testutil.RequireProtoSlicesEqual(t, expected, actual.Pairs, nil, "response bulk check pairs did not match") }) } diff --git a/internal/testfixtures/datastore.go b/internal/testfixtures/datastore.go index b73d9235dc..cc448ed5e3 100644 --- a/internal/testfixtures/datastore.go +++ b/internal/testfixtures/datastore.go @@ -4,7 +4,6 @@ import ( "context" "github.com/stretchr/testify/require" - "google.golang.org/protobuf/types/known/structpb" "github.com/authzed/spicedb/internal/datastore/common" "github.com/authzed/spicedb/internal/namespace" @@ -141,6 +140,10 @@ var StandardRelationships = []string{ "document:ownerplan#viewer@user:owner#...", } +var StandardCaveatedRelationships = []string{ + "document:caveatedplan#caveated_viewer@user:caveatedguy#...[test:{\"expectedSecret\":\"1234\"}]", +} + // EmptyDatastore returns an empty datastore for testing. func EmptyDatastore(ds datastore.Datastore, require *require.Assertions) (datastore.Datastore, datastore.Revision) { rev, err := ds.HeadRevision(context.Background()) @@ -185,16 +188,17 @@ func StandardDatastoreWithCaveatedData(ds datastore.Datastore, require *require. }) require.NoError(err) - rels := make([]tuple.Relationship, 0, len(StandardRelationships)) + rels := make([]tuple.Relationship, 0, len(StandardRelationships)+len(StandardCaveatedRelationships)) for _, tupleStr := range StandardRelationships { rel, err := tuple.Parse(tupleStr) require.NoError(err) require.NotNil(rel) - - rel.OptionalCaveat = &core.ContextualizedCaveat{ - CaveatName: "test", - Context: mustProtoStruct(map[string]any{"expectedSecret": "1234"}), - } + rels = append(rels, rel) + } + for _, tupleStr := range StandardCaveatedRelationships { + rel, err := tuple.Parse(tupleStr) + require.NoError(err) + require.NotNil(rel) rels = append(rels, rel) } @@ -360,11 +364,3 @@ func (tc RelationshipChecker) NoRelationshipExists(ctx context.Context, rel tupl iter := tc.ExactRelationshipIterator(ctx, rel, rev) tc.VerifyIteratorResults(iter) } - -func mustProtoStruct(in map[string]any) *structpb.Struct { - out, err := structpb.NewStruct(in) - if err != nil { - panic(err) - } - return out -} diff --git a/pkg/cmd/datastore/datastore.go b/pkg/cmd/datastore/datastore.go index fb9ec248a6..0f4c8094ac 100644 --- a/pkg/cmd/datastore/datastore.go +++ b/pkg/cmd/datastore/datastore.go @@ -167,7 +167,8 @@ type Config struct { AllowedMigrations []string `debugmap:"visible"` // Expermimental - ExperimentalColumnOptimization bool `debugmap:"visible"` + ExperimentalColumnOptimization bool `debugmap:"visible"` + EnableExperimentalRelationshipExpiration bool `debugmap:"visible"` } //go:generate go run github.com/ecordell/optgen -sensitive-field-name-matches uri,secure -output zz_generated.relintegritykey.options.go . RelIntegrityKey @@ -279,48 +280,49 @@ func RegisterDatastoreFlagsWithPrefix(flagSet *pflag.FlagSet, prefix string, opt func DefaultDatastoreConfig() *Config { return &Config{ - Engine: MemoryEngine, - GCWindow: 24 * time.Hour, - LegacyFuzzing: -1, - RevisionQuantization: 5 * time.Second, - MaxRevisionStalenessPercent: .1, // 10% - ReadConnPool: *DefaultReadConnPool(), - WriteConnPool: *DefaultWriteConnPool(), - ReadReplicaConnPool: *DefaultReadConnPool(), - ReadReplicaURIs: []string{}, - ReadOnly: false, - MaxRetries: 10, - OverlapKey: "key", - OverlapStrategy: "static", - ConnectRate: 100 * time.Millisecond, - EnableConnectionBalancing: true, - GCInterval: 3 * time.Minute, - GCMaxOperationTime: 1 * time.Minute, - WatchBufferLength: 1024, - WatchBufferWriteTimeout: 1 * time.Second, - WatchConnectTimeout: 1 * time.Second, - EnableDatastoreMetrics: true, - DisableStats: false, - BootstrapFiles: []string{}, - BootstrapTimeout: 10 * time.Second, - BootstrapOverwrite: false, - RequestHedgingEnabled: false, - RequestHedgingInitialSlowValue: 10000000, - RequestHedgingMaxRequests: 1_000_000, - RequestHedgingQuantile: 0.95, - SpannerCredentialsFile: "", - SpannerEmulatorHost: "", - TablePrefix: "", - MigrationPhase: "", - FollowerReadDelay: 4_800 * time.Millisecond, - SpannerMinSessions: 100, - SpannerMaxSessions: 400, - FilterMaximumIDCount: 100, - RelationshipIntegrityEnabled: false, - RelationshipIntegrityCurrentKey: RelIntegrityKey{}, - RelationshipIntegrityExpiredKeys: []string{}, - AllowedMigrations: []string{}, - ExperimentalColumnOptimization: false, + Engine: MemoryEngine, + GCWindow: 24 * time.Hour, + LegacyFuzzing: -1, + RevisionQuantization: 5 * time.Second, + MaxRevisionStalenessPercent: .1, // 10% + ReadConnPool: *DefaultReadConnPool(), + WriteConnPool: *DefaultWriteConnPool(), + ReadReplicaConnPool: *DefaultReadConnPool(), + ReadReplicaURIs: []string{}, + ReadOnly: false, + MaxRetries: 10, + OverlapKey: "key", + OverlapStrategy: "static", + ConnectRate: 100 * time.Millisecond, + EnableConnectionBalancing: true, + GCInterval: 3 * time.Minute, + GCMaxOperationTime: 1 * time.Minute, + WatchBufferLength: 1024, + WatchBufferWriteTimeout: 1 * time.Second, + WatchConnectTimeout: 1 * time.Second, + EnableDatastoreMetrics: true, + DisableStats: false, + BootstrapFiles: []string{}, + BootstrapTimeout: 10 * time.Second, + BootstrapOverwrite: false, + RequestHedgingEnabled: false, + RequestHedgingInitialSlowValue: 10000000, + RequestHedgingMaxRequests: 1_000_000, + RequestHedgingQuantile: 0.95, + SpannerCredentialsFile: "", + SpannerEmulatorHost: "", + TablePrefix: "", + MigrationPhase: "", + FollowerReadDelay: 4_800 * time.Millisecond, + SpannerMinSessions: 100, + SpannerMaxSessions: 400, + FilterMaximumIDCount: 100, + RelationshipIntegrityEnabled: false, + RelationshipIntegrityCurrentKey: RelIntegrityKey{}, + RelationshipIntegrityExpiredKeys: []string{}, + AllowedMigrations: []string{}, + ExperimentalColumnOptimization: false, + EnableExperimentalRelationshipExpiration: false, } } @@ -516,6 +518,7 @@ func newCRDBDatastore(ctx context.Context, opts Config) (datastore.Datastore, er crdb.WithIntegrity(opts.RelationshipIntegrityEnabled), crdb.AllowedMigrations(opts.AllowedMigrations), crdb.WithColumnOptimization(opts.ExperimentalColumnOptimization), + crdb.WithExpirationDisabled(!opts.EnableExperimentalRelationshipExpiration), ) } @@ -557,6 +560,7 @@ func commonPostgresDatastoreOptions(opts Config) ([]postgres.Option, error) { postgres.MaxRetries(maxRetries), postgres.FilterMaximumIDCount(opts.FilterMaximumIDCount), postgres.WithColumnOptimization(opts.ExperimentalColumnOptimization), + postgres.WithExpirationDisabled(!opts.EnableExperimentalRelationshipExpiration), }, nil } @@ -640,6 +644,7 @@ func newSpannerDatastore(ctx context.Context, opts Config) (datastore.Datastore, spanner.AllowedMigrations(opts.AllowedMigrations), spanner.FilterMaximumIDCount(opts.FilterMaximumIDCount), spanner.WithColumnOptimization(opts.ExperimentalColumnOptimization), + spanner.WithExpirationDisabled(!opts.EnableExperimentalRelationshipExpiration), ) } @@ -685,6 +690,7 @@ func commonMySQLDatastoreOptions(opts Config) ([]mysql.Option, error) { mysql.FilterMaximumIDCount(opts.FilterMaximumIDCount), mysql.AllowedMigrations(opts.AllowedMigrations), mysql.WithColumnOptimization(opts.ExperimentalColumnOptimization), + mysql.WithExpirationDisabled(!opts.EnableExperimentalRelationshipExpiration), }, nil } diff --git a/pkg/cmd/datastore/zz_generated.options.go b/pkg/cmd/datastore/zz_generated.options.go index 0e41eee6d5..45c8885e75 100644 --- a/pkg/cmd/datastore/zz_generated.options.go +++ b/pkg/cmd/datastore/zz_generated.options.go @@ -78,6 +78,7 @@ func (c *Config) ToOption() ConfigOption { to.MigrationPhase = c.MigrationPhase to.AllowedMigrations = c.AllowedMigrations to.ExperimentalColumnOptimization = c.ExperimentalColumnOptimization + to.EnableExperimentalRelationshipExpiration = c.EnableExperimentalRelationshipExpiration } } @@ -130,6 +131,7 @@ func (c Config) DebugMap() map[string]any { debugMap["MigrationPhase"] = helpers.DebugValue(c.MigrationPhase, false) debugMap["AllowedMigrations"] = helpers.DebugValue(c.AllowedMigrations, false) debugMap["ExperimentalColumnOptimization"] = helpers.DebugValue(c.ExperimentalColumnOptimization, false) + debugMap["EnableExperimentalRelationshipExpiration"] = helpers.DebugValue(c.EnableExperimentalRelationshipExpiration, false) return debugMap } @@ -519,3 +521,10 @@ func WithExperimentalColumnOptimization(experimentalColumnOptimization bool) Con c.ExperimentalColumnOptimization = experimentalColumnOptimization } } + +// WithEnableExperimentalRelationshipExpiration returns an option that can set EnableExperimentalRelationshipExpiration on a Config +func WithEnableExperimentalRelationshipExpiration(enableExperimentalRelationshipExpiration bool) ConfigOption { + return func(c *Config) { + c.EnableExperimentalRelationshipExpiration = enableExperimentalRelationshipExpiration + } +} diff --git a/pkg/cmd/server/server.go b/pkg/cmd/server/server.go index 5f2ca4a37b..3ded6efb7a 100644 --- a/pkg/cmd/server/server.go +++ b/pkg/cmd/server/server.go @@ -226,7 +226,9 @@ func (c *Config) Complete(ctx context.Context) (RunnableServer, error) { ds, err = datastorecfg.NewDatastore(context.Background(), c.DatastoreConfig.ToOption(), // Datastore's filter maximum ID count is set to the max size, since the number of elements to be dispatched // are at most the number of elements returned from a datastore query - datastorecfg.WithFilterMaximumIDCount(c.DispatchChunkSize)) + datastorecfg.WithFilterMaximumIDCount(c.DispatchChunkSize), + datastorecfg.WithEnableExperimentalRelationshipExpiration(c.EnableExperimentalRelationshipExpiration), + ) if err != nil { return nil, spiceerrors.NewTerminationErrorBuilder(fmt.Errorf("failed to create datastore: %w", err)). Component("datastore"). diff --git a/pkg/datastore/options/options.go b/pkg/datastore/options/options.go index 6a55c0582d..749005e136 100644 --- a/pkg/datastore/options/options.go +++ b/pkg/datastore/options/options.go @@ -43,10 +43,11 @@ func ToRelationship(c Cursor) *tuple.Relationship { // QueryOptions are the options that can affect the results of a normal forward query. type QueryOptions struct { - Limit *uint64 `debugmap:"visible"` - Sort SortOrder `debugmap:"visible"` - After Cursor `debugmap:"visible"` - SkipCaveats bool `debugmap:"visible"` + Limit *uint64 `debugmap:"visible"` + Sort SortOrder `debugmap:"visible"` + After Cursor `debugmap:"visible"` + SkipCaveats bool `debugmap:"visible"` + SkipExpiration bool `debugmap:"visible"` } // ReverseQueryOptions are the options that can affect the results of a reverse query. diff --git a/pkg/datastore/options/zz_generated.query_options.go b/pkg/datastore/options/zz_generated.query_options.go index 79db45999e..493e1c73d2 100644 --- a/pkg/datastore/options/zz_generated.query_options.go +++ b/pkg/datastore/options/zz_generated.query_options.go @@ -35,6 +35,7 @@ func (q *QueryOptions) ToOption() QueryOptionsOption { to.Sort = q.Sort to.After = q.After to.SkipCaveats = q.SkipCaveats + to.SkipExpiration = q.SkipExpiration } } @@ -45,6 +46,7 @@ func (q QueryOptions) DebugMap() map[string]any { debugMap["Sort"] = helpers.DebugValue(q.Sort, false) debugMap["After"] = helpers.DebugValue(q.After, false) debugMap["SkipCaveats"] = helpers.DebugValue(q.SkipCaveats, false) + debugMap["SkipExpiration"] = helpers.DebugValue(q.SkipExpiration, false) return debugMap } @@ -92,6 +94,13 @@ func WithSkipCaveats(skipCaveats bool) QueryOptionsOption { } } +// WithSkipExpiration returns an option that can set SkipExpiration on a QueryOptions +func WithSkipExpiration(skipExpiration bool) QueryOptionsOption { + return func(q *QueryOptions) { + q.SkipExpiration = skipExpiration + } +} + type ReverseQueryOptionsOption func(r *ReverseQueryOptions) // NewReverseQueryOptionsWithOptions creates a new ReverseQueryOptions with the passed in options set diff --git a/pkg/datastore/test/relationships.go b/pkg/datastore/test/relationships.go index 63d2f39c1a..9d75d1419f 100644 --- a/pkg/datastore/test/relationships.go +++ b/pkg/datastore/test/relationships.go @@ -1003,11 +1003,12 @@ func RecreateRelationshipsAfterDeleteWithFilter(t *testing.T, tester DatastoreTe // QueryRelationshipsWithVariousFiltersTest tests various relationship filters for query relationships. func QueryRelationshipsWithVariousFiltersTest(t *testing.T, tester DatastoreTester) { tcs := []struct { - name string - filter datastore.RelationshipsFilter - withoutCaveats bool - relationships []string - expected []string + name string + filter datastore.RelationshipsFilter + withoutCaveats bool + withoutExpiration bool + relationships []string + expected []string }{ { name: "resource type", @@ -1430,6 +1431,24 @@ func QueryRelationshipsWithVariousFiltersTest(t *testing.T, tester DatastoreTest "document:first#viewer@user:tom", }, }, + { + name: "no caveats and no expiration", + filter: datastore.RelationshipsFilter{ + OptionalResourceType: "document", + }, + relationships: []string{ + "document:first#viewer@user:tom", + "document:first#viewer@user:fred", + "document:first#viewer@user:sarah", + }, + expected: []string{ + "document:first#viewer@user:tom", + "document:first#viewer@user:fred", + "document:first#viewer@user:sarah", + }, + withoutCaveats: true, + withoutExpiration: true, + }, } for _, tc := range tcs { @@ -1453,7 +1472,7 @@ func QueryRelationshipsWithVariousFiltersTest(t *testing.T, tester DatastoreTest require.NoError(err) reader := ds.SnapshotReader(headRev) - iter, err := reader.QueryRelationships(ctx, tc.filter, options.WithSkipCaveats(tc.withoutCaveats)) + iter, err := reader.QueryRelationships(ctx, tc.filter, options.WithSkipCaveats(tc.withoutCaveats), options.WithSkipExpiration(tc.withoutExpiration)) require.NoError(err) var results []string