Ctrl K
ComunidadGuías…

filter/functions_test.go

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
package filter

import (
	"context"
	"testing"
	"time"

	"github.com/stretchr/testify/require"
)

// ---------------------------------------------------------------------------
// Arithmetic folding: division and modulo
// ---------------------------------------------------------------------------

func TestCompileDivisionFolds(t *testing.T) {
	t.Parallel()

	engine, err := NewEngine(NewSchema())
	require.NoError(t, err)
	stmt, err := engine.CompileToStatement(context.Background(), `creator_id == 100 / 10`, RenderOptions{Dialect: DialectSQLite})
	require.NoError(t, err)
	require.Equal(t, []any{int64(10)}, stmt.Args)
}

func TestCompileModuloFolds(t *testing.T) {
	t.Parallel()

	engine, err := NewEngine(NewSchema())
	require.NoError(t, err)
	stmt, err := engine.CompileToStatement(context.Background(), `creator_id == 17 % 5`, RenderOptions{Dialect: DialectSQLite})
	require.NoError(t, err)
	require.Equal(t, []any{int64(2)}, stmt.Args)
}

func TestCompileDivisionByZeroErrors(t *testing.T) {
	t.Parallel()

	engine, err := NewEngine(NewSchema())
	require.NoError(t, err)
	_, err = engine.Compile(context.Background(), `creator_id == 10 / 0`)
	require.Error(t, err)
}

// ---------------------------------------------------------------------------
// size() on scalar string fields -> SQL length
// ---------------------------------------------------------------------------

func TestCompileSizeOnContentRendersLengthPerDialect(t *testing.T) {
	t.Parallel()

	engine, err := NewEngine(NewSchema())
	require.NoError(t, err)

	cases := []struct {
		dialect  DialectName
		fragment string
	}{
		{DialectSQLite, "LENGTH("},
		{DialectPostgres, "LENGTH("},
		{DialectMySQL, "CHAR_LENGTH("},
	}
	for _, tc := range cases {
		stmt, err := engine.CompileToStatement(context.Background(), `size(content) > 5`, RenderOptions{Dialect: tc.dialect})
		require.NoError(t, err, tc.dialect)
		require.Contains(t, stmt.SQL, tc.fragment, "dialect %s", tc.dialect)
		require.Equal(t, []any{int64(5)}, stmt.Args, "dialect %s", tc.dialect)
	}
}

// ---------------------------------------------------------------------------
// Timestamp accessor methods (getFullYear, getMonth, ...)
// ---------------------------------------------------------------------------

func TestCompileTimestampAccessorsPerDialect(t *testing.T) {
	t.Parallel()

	engine, err := NewEngine(NewSchema())
	require.NoError(t, err)

	cases := []struct {
		name      string
		filter    string
		dialect   DialectName
		fragments []string
		arg       int64
	}{
		// getFullYear == 2024
		{"sqlite year", `created_ts.getFullYear() == 2024`, DialectSQLite, []string{"strftime('%Y'", "'unixepoch'"}, 2024},
		{"pg year", `created_ts.getFullYear() == 2024`, DialectPostgres, []string{"EXTRACT(YEAR FROM to_timestamp(", "AT TIME ZONE 'UTC'"}, 2024},
		{"mysql year", `created_ts.getFullYear() == 2024`, DialectMySQL, []string{"YEAR(`memo`.`created_ts`)"}, 2024},
		// getMonth is 0-based -> SQL must subtract 1
		{"sqlite month", `created_ts.getMonth() == 5`, DialectSQLite, []string{"strftime('%m'", "- 1)"}, 5},
		{"pg month", `created_ts.getMonth() == 5`, DialectPostgres, []string{"EXTRACT(MONTH FROM", "- 1)"}, 5},
		{"mysql month", `created_ts.getMonth() == 5`, DialectMySQL, []string{"MONTH(`memo`.`created_ts`)", "- 1)"}, 5},
		// getDayOfWeek 0=Sunday -> MySQL DAYOFWEEK is 1-based and must subtract 1
		{"mysql dow", `created_ts.getDayOfWeek() == 0`, DialectMySQL, []string{"DAYOFWEEK(`memo`.`created_ts`)", "- 1)"}, 0},
		{"sqlite dow", `created_ts.getDayOfWeek() == 0`, DialectSQLite, []string{"strftime('%w'"}, 0},
		// getDate is 1-based -> no offset
		{"sqlite date", `created_ts.getDate() == 22`, DialectSQLite, []string{"strftime('%d'"}, 22},
	}
	for _, tc := range cases {
		stmt, err := engine.CompileToStatement(context.Background(), tc.filter, RenderOptions{Dialect: tc.dialect})
		require.NoError(t, err, tc.name)
		for _, frag := range tc.fragments {
			require.Contains(t, stmt.SQL, frag, tc.name)
		}
		require.Equal(t, []any{tc.arg}, stmt.Args, tc.name)
	}
}

func TestCompileNowAccessorsFoldToInjectedClock(t *testing.T) {
	t.Parallel()

	// 2026-07-07T10:30:45Z, a Tuesday (year day 188).
	engine := memoEngineAt(t, time.Date(2026, time.July, 7, 10, 30, 45, 0, time.UTC).Unix())

	cases := []struct {
		name   string
		filter string
		args   []any
	}{
		{"on this day", `created_ts.getMonth() == now.getMonth() && created_ts.getDate() == now.getDate()`, []any{int64(6), int64(7)}},
		{"year", `created_ts.getFullYear() < now.getFullYear()`, []any{int64(2026)}},
		{"day of month", `created_ts.getDayOfMonth() == now.getDayOfMonth()`, []any{int64(6)}},
		{"day of week", `created_ts.getDayOfWeek() == now.getDayOfWeek()`, []any{int64(2)}},
		{"day of year", `created_ts.getDayOfYear() == now.getDayOfYear()`, []any{int64(187)}},
		{"clock parts", `created_ts.getHours() == now.getHours() || created_ts.getMinutes() == now.getMinutes() || created_ts.getSeconds() == now.getSeconds()`, []any{int64(10), int64(30), int64(45)}},
	}
	for _, tc := range cases {
		stmt, err := engine.CompileToStatement(context.Background(), tc.filter, RenderOptions{Dialect: DialectSQLite})
		require.NoError(t, err, tc.name)
		require.Equal(t, tc.args, stmt.Args, tc.name)
	}
}

func TestCompileNowAccessorRejectsTimezoneArg(t *testing.T) {
	t.Parallel()

	engine, err := NewEngine(NewSchema())
	require.NoError(t, err)
	_, err = engine.Compile(context.Background(), `created_ts.getMonth() == now.getMonth("America/New_York")`)
	require.Error(t, err)
}

func TestCompileOnThisDayPerDialect(t *testing.T) {
	t.Parallel()

	const onThisDay = `created_ts.getMonth() == now.getMonth() && created_ts.getDate() == now.getDate()`
	engine := memoEngineAt(t, time.Date(2026, time.July, 7, 10, 30, 45, 0, time.UTC).Unix())

	cases := []struct {
		dialect   DialectName
		fragments []string
	}{
		{DialectSQLite, []string{"strftime('%m'", "strftime('%d'", "'unixepoch'"}},
		{DialectPostgres, []string{"EXTRACT(MONTH FROM", "EXTRACT(DAY FROM"}},
		{DialectMySQL, []string{"MONTH(`memo`.`created_ts`)", "DAYOFMONTH(`memo`.`created_ts`)"}},
	}
	for _, tc := range cases {
		stmt, err := engine.CompileToStatement(context.Background(), onThisDay, RenderOptions{Dialect: tc.dialect})
		require.NoError(t, err, tc.dialect)
		for _, frag := range tc.fragments {
			require.Contains(t, stmt.SQL, frag, "dialect %s", tc.dialect)
		}
		require.Equal(t, []any{int64(6), int64(7)}, stmt.Args, "dialect %s", tc.dialect)
	}
}

func TestCompileNowAccessorsAtBoundaryDates(t *testing.T) {
	t.Parallel()

	// Every accessor folded at calendar edges: year rollover, leap day, Sunday.
	allAccessors := `created_ts.getFullYear() == now.getFullYear() ` +
		`&& created_ts.getMonth() == now.getMonth() ` +
		`&& created_ts.getDate() == now.getDate() ` +
		`&& created_ts.getDayOfMonth() == now.getDayOfMonth() ` +
		`&& created_ts.getDayOfWeek() == now.getDayOfWeek() ` +
		`&& created_ts.getDayOfYear() == now.getDayOfYear() ` +
		`&& created_ts.getHours() == now.getHours() ` +
		`&& created_ts.getMinutes() == now.getMinutes() ` +
		`&& created_ts.getSeconds() == now.getSeconds()`

	cases := []struct {
		name  string
		clock time.Time
		// year, month, date, dayOfMonth, dayOfWeek, dayOfYear, hours, minutes, seconds
		args []any
	}{
		{
			"new year's eve",
			time.Date(2026, time.December, 31, 23, 59, 59, 0, time.UTC),
			[]any{int64(2026), int64(11), int64(31), int64(30), int64(4), int64(364), int64(23), int64(59), int64(59)},
		},
		{
			"new year's day",
			time.Date(2027, time.January, 1, 0, 0, 0, 0, time.UTC),
			[]any{int64(2027), int64(0), int64(1), int64(0), int64(5), int64(0), int64(0), int64(0), int64(0)},
		},
		{
			"leap day",
			time.Date(2028, time.February, 29, 12, 0, 0, 0, time.UTC),
			[]any{int64(2028), int64(1), int64(29), int64(28), int64(2), int64(59), int64(12), int64(0), int64(0)},
		},
		{
			"sunday",
			time.Date(2026, time.July, 5, 8, 15, 30, 0, time.UTC),
			[]any{int64(2026), int64(6), int64(5), int64(4), int64(0), int64(185), int64(8), int64(15), int64(30)},
		},
	}
	for _, tc := range cases {
		engine := memoEngineAt(t, tc.clock.Unix())
		stmt, err := engine.CompileToStatement(context.Background(), allAccessors, RenderOptions{Dialect: DialectSQLite})
		require.NoError(t, err, tc.name)
		require.Equal(t, tc.args, stmt.Args, tc.name)
	}
}

func TestCompileNowAccessorFoldsInUTC(t *testing.T) {
	t.Parallel()

	// A clock in UTC+8 at 05:00 on July 8 is still July 7, 21:00 in UTC;
	// folding must not leak the clock's zone.
	engine, err := NewEngine(NewSchema())
	require.NoError(t, err)
	engine.nowFunc = func() time.Time {
		return time.Date(2026, time.July, 8, 5, 0, 0, 0, time.FixedZone("UTC+8", 8*3600))
	}

	stmt, err := engine.CompileToStatement(
		context.Background(),
		`created_ts.getDate() == now.getDate() && created_ts.getHours() == now.getHours()`,
		RenderOptions{Dialect: DialectSQLite},
	)
	require.NoError(t, err)
	require.Equal(t, []any{int64(7), int64(21)}, stmt.Args)
}

func TestCompileNowAccessorOnLeftSide(t *testing.T) {
	t.Parallel()

	engine := memoEngineAt(t, time.Date(2026, time.July, 7, 10, 30, 45, 0, time.UTC).Unix())
	stmt, err := engine.CompileToStatement(context.Background(), `now.getMonth() == created_ts.getMonth()`, RenderOptions{Dialect: DialectSQLite})
	require.NoError(t, err)
	require.Contains(t, stmt.SQL, "strftime('%m'")
	require.Equal(t, []any{int64(6)}, stmt.Args)
}

func TestCompileNowAccessorMirrorsOrderingOperator(t *testing.T) {
	t.Parallel()

	// Swapping the literal to the right must flip < to > to keep the meaning.
	engine := memoEngineAt(t, time.Date(2026, time.July, 7, 10, 30, 45, 0, time.UTC).Unix())
	stmt, err := engine.CompileToStatement(context.Background(), `now.getHours() < created_ts.getHours()`, RenderOptions{Dialect: DialectSQLite})
	require.NoError(t, err)
	require.Contains(t, stmt.SQL, "> ?")
	require.Equal(t, []any{int64(10)}, stmt.Args)
}

func TestCompileNowAccessorAgainstLiteralFoldsToConstant(t *testing.T) {
	t.Parallel()

	// Both sides fold to literals; the comparison folds to a constant condition.
	engine := memoEngineAt(t, time.Date(2026, time.July, 7, 10, 30, 45, 0, time.UTC).Unix())

	// True → trivial filter (empty SQL, matches everything).
	stmt, err := engine.CompileToStatement(context.Background(), `now.getFullYear() >= 2026`, RenderOptions{Dialect: DialectSQLite})
	require.NoError(t, err)
	require.Empty(t, stmt.SQL)
	require.Empty(t, stmt.Args)

	// False → unsatisfiable filter.
	stmt, err = engine.CompileToStatement(context.Background(), `now.getFullYear() < 2026`, RenderOptions{Dialect: DialectSQLite})
	require.NoError(t, err)
	require.Equal(t, "1 = 0", stmt.SQL)
	require.Empty(t, stmt.Args)
}

func TestCompileAttachmentNowAccessors(t *testing.T) {
	t.Parallel()

	engine, err := NewEngine(NewAttachmentSchema())
	require.NoError(t, err)
	engine.nowFunc = fixedClock(time.Date(2026, time.July, 7, 10, 30, 45, 0, time.UTC).Unix())

	stmt, err := engine.CompileToStatement(
		context.Background(),
		`create_time.getMonth() == now.getMonth() && create_time.getDate() == now.getDate()`,
		RenderOptions{Dialect: DialectSQLite},
	)
	require.NoError(t, err)
	require.Contains(t, stmt.SQL, "`attachment`.`created_ts`")
	require.Equal(t, []any{int64(6), int64(7)}, stmt.Args)
}

func TestCompileTimestampAccessorRejectsTimezoneArg(t *testing.T) {
	t.Parallel()

	engine, err := NewEngine(NewSchema())
	require.NoError(t, err)
	_, err = engine.Compile(context.Background(), `created_ts.getHours("America/New_York") == 9`)
	require.Error(t, err)
}

func TestCompileTimestampAccessorRejectsNonTimestampField(t *testing.T) {
	t.Parallel()

	engine, err := NewEngine(NewSchema())
	require.NoError(t, err)
	// content is a string, not a timestamp.
	_, err = engine.Compile(context.Background(), `content.getFullYear() == 2024`)
	require.Error(t, err)
}

// ---------------------------------------------------------------------------
// ext.Sets(): sets.contains / sets.intersects / sets.equivalent over tags
// ---------------------------------------------------------------------------

func TestCompileSetsContainsRendersAndOfMemberships(t *testing.T) {
	t.Parallel()

	engine, err := NewEngine(NewSchema())
	require.NoError(t, err)
	stmt, err := engine.CompileToStatement(context.Background(), `sets.contains(tags, ["a", "b"])`, RenderOptions{Dialect: DialectSQLite})
	require.NoError(t, err)
	require.Contains(t, stmt.SQL, " AND ")
	require.Len(t, stmt.Args, 2)
}

func TestCompileSetsIntersectsRendersOrOfMemberships(t *testing.T) {
	t.Parallel()

	engine, err := NewEngine(NewSchema())
	require.NoError(t, err)
	stmt, err := engine.CompileToStatement(context.Background(), `sets.intersects(tags, ["a", "b"])`, RenderOptions{Dialect: DialectSQLite})
	require.NoError(t, err)
	require.Contains(t, stmt.SQL, " OR ")
	require.Len(t, stmt.Args, 2)
}

func TestCompileSetsEquivalentAddsLengthCheck(t *testing.T) {
	t.Parallel()

	engine, err := NewEngine(NewSchema())
	require.NoError(t, err)
	stmt, err := engine.CompileToStatement(context.Background(), `sets.equivalent(tags, ["a", "b"])`, RenderOptions{Dialect: DialectSQLite})
	require.NoError(t, err)
	require.Contains(t, stmt.SQL, "JSON_ARRAY_LENGTH")
	require.Contains(t, stmt.Args, int64(2))
}

// ---------------------------------------------------------------------------
// exists_one() comprehension on tags
// ---------------------------------------------------------------------------

func TestCompileExistsOnePerDialect(t *testing.T) {
	t.Parallel()

	engine, err := NewEngine(NewSchema())
	require.NoError(t, err)

	cases := []struct {
		dialect   DialectName
		fragments []string
	}{
		{DialectSQLite, []string{"COUNT(", "json_each(", ") = 1"}},
		{DialectPostgres, []string{"COUNT(", "jsonb_array_elements_text(", ") = 1"}},
		{DialectMySQL, []string{"COUNT(", "JSON_TABLE(", ") = 1"}},
	}
	for _, tc := range cases {
		stmt, err := engine.CompileToStatement(context.Background(), `tags.exists_one(t, t == "urgent")`, RenderOptions{Dialect: tc.dialect})
		require.NoError(t, err, tc.dialect)
		for _, frag := range tc.fragments {
			require.Contains(t, stmt.SQL, frag, "dialect %s", tc.dialect)
		}
		require.Equal(t, []any{"urgent"}, stmt.Args, "dialect %s", tc.dialect)
	}
}