|
| 1 | +"""End-to-end schema discovery against a real PostgreSQL instance.""" |
| 2 | + |
| 3 | +import pytest |
| 4 | +from testcontainers.postgres import PostgresContainer |
| 5 | + |
| 6 | +from feast.schema_discovery import SchemaDiscoveryService |
| 7 | +from feast.schema_discovery.errors import ( |
| 8 | + DatabaseConnectionError, |
| 9 | + SchemaNotFoundError, |
| 10 | + TableNotFoundError, |
| 11 | +) |
| 12 | + |
| 13 | +DDL = """ |
| 14 | +CREATE TABLE public.users ( |
| 15 | + user_id BIGINT PRIMARY KEY, |
| 16 | + email VARCHAR(255), |
| 17 | + age INTEGER NOT NULL, |
| 18 | + balance DOUBLE PRECISION, |
| 19 | + is_active BOOLEAN NOT NULL, |
| 20 | + created_at TIMESTAMP NOT NULL, |
| 21 | + metadata JSONB |
| 22 | +); |
| 23 | +
|
| 24 | +CREATE VIEW public.active_users AS |
| 25 | + SELECT user_id, email FROM public.users WHERE is_active; |
| 26 | +
|
| 27 | +CREATE SCHEMA analytics; |
| 28 | +CREATE TABLE analytics.daily_metrics ( |
| 29 | + metric_date DATE PRIMARY KEY, |
| 30 | + value NUMERIC |
| 31 | +); |
| 32 | +""" |
| 33 | + |
| 34 | + |
| 35 | +@pytest.fixture(scope="module") |
| 36 | +def postgres_url(): |
| 37 | + with PostgresContainer( |
| 38 | + "postgres:16", |
| 39 | + username="root", |
| 40 | + password="test!@#$%", # pragma: allowlist secret |
| 41 | + dbname="test", |
| 42 | + ).with_exposed_ports(5432) as container: |
| 43 | + import psycopg |
| 44 | + |
| 45 | + host = container.get_container_host_ip() |
| 46 | + port = container.get_exposed_port(5432) |
| 47 | + url = ( # pragma: allowlist secret |
| 48 | + f"postgresql://root:test!%40%23%24%25@{host}:{port}/test?sslmode=disable" |
| 49 | + ) |
| 50 | + |
| 51 | + with psycopg.connect( |
| 52 | + host=host, |
| 53 | + port=port, |
| 54 | + user="root", |
| 55 | + password="test!@#$%", # pragma: allowlist secret |
| 56 | + dbname="test", |
| 57 | + sslmode="disable", |
| 58 | + ) as conn: |
| 59 | + conn.execute(DDL) |
| 60 | + conn.commit() |
| 61 | + |
| 62 | + yield url |
| 63 | + |
| 64 | + |
| 65 | +@pytest.fixture |
| 66 | +def service(): |
| 67 | + return SchemaDiscoveryService(connect_timeout=10) |
| 68 | + |
| 69 | + |
| 70 | +@pytest.mark.integration |
| 71 | +class TestListSchemas: |
| 72 | + def test_lists_user_schemas_only(self, service, postgres_url): |
| 73 | + result = service.list_schemas(postgres_url, include_tables=False) |
| 74 | + names = {s.name for s in result.schemas} |
| 75 | + assert {"public", "analytics"} <= names |
| 76 | + assert not any(n.startswith("pg_") for n in names) |
| 77 | + assert "information_schema" not in names |
| 78 | + |
| 79 | + def test_reports_database_and_dialect(self, service, postgres_url): |
| 80 | + result = service.list_schemas(postgres_url, include_tables=False) |
| 81 | + assert result.database == "test" |
| 82 | + assert result.dialect == "postgresql" |
| 83 | + |
| 84 | + def test_includes_tables_and_views(self, service, postgres_url): |
| 85 | + result = service.list_schemas(postgres_url, include_tables=True) |
| 86 | + public = next(s for s in result.schemas if s.name == "public") |
| 87 | + by_name = {t.name: t.type for t in public.tables} |
| 88 | + assert by_name["users"] == "table" |
| 89 | + assert by_name["active_users"] == "view" |
| 90 | + |
| 91 | + def test_scopes_to_one_schema(self, service, postgres_url): |
| 92 | + result = service.list_schemas(postgres_url, schema="analytics") |
| 93 | + assert [s.name for s in result.schemas] == ["analytics"] |
| 94 | + assert [t.name for t in result.schemas[0].tables] == ["daily_metrics"] |
| 95 | + |
| 96 | + def test_unknown_schema_raises(self, service, postgres_url): |
| 97 | + with pytest.raises(SchemaNotFoundError): |
| 98 | + service.list_schemas(postgres_url, schema="does_not_exist") |
| 99 | + |
| 100 | + |
| 101 | +@pytest.mark.integration |
| 102 | +class TestListTables: |
| 103 | + def test_lists_tables_in_schema(self, service, postgres_url): |
| 104 | + tables = service.list_tables(postgres_url, "analytics") |
| 105 | + assert [t.name for t in tables] == ["daily_metrics"] |
| 106 | + |
| 107 | + def test_unknown_schema_raises(self, service, postgres_url): |
| 108 | + with pytest.raises(SchemaNotFoundError): |
| 109 | + service.list_tables(postgres_url, "does_not_exist") |
| 110 | + |
| 111 | + |
| 112 | +@pytest.mark.integration |
| 113 | +class TestDescribeTable: |
| 114 | + def test_maps_every_column_to_a_feast_type(self, service, postgres_url): |
| 115 | + result = service.describe_table(postgres_url, "public.users") |
| 116 | + by_name = {c.name: c for c in result.columns} |
| 117 | + |
| 118 | + assert by_name["user_id"].feast_type == "INT64" |
| 119 | + assert by_name["email"].feast_type == "STRING" |
| 120 | + assert by_name["age"].feast_type == "INT32" |
| 121 | + assert by_name["balance"].feast_type == "DOUBLE" |
| 122 | + assert by_name["is_active"].feast_type == "BOOL" |
| 123 | + assert by_name["created_at"].feast_type == "UNIX_TIMESTAMP" |
| 124 | + assert by_name["metadata"].feast_type == "MAP" |
| 125 | + |
| 126 | + def test_no_column_falls_back_to_unknown(self, service, postgres_url): |
| 127 | + """Regression guard: SQLAlchemy type spellings would map to UNKNOWN.""" |
| 128 | + result = service.describe_table(postgres_url, "public.users") |
| 129 | + assert all(c.feast_type != "UNKNOWN" for c in result.columns) |
| 130 | + |
| 131 | + def test_reports_primary_key(self, service, postgres_url): |
| 132 | + result = service.describe_table(postgres_url, "public.users") |
| 133 | + pks = {c.name for c in result.columns if c.primary_key} |
| 134 | + assert pks == {"user_id"} |
| 135 | + |
| 136 | + def test_reports_nullability(self, service, postgres_url): |
| 137 | + by_name = { |
| 138 | + c.name: c |
| 139 | + for c in service.describe_table(postgres_url, "public.users").columns |
| 140 | + } |
| 141 | + assert by_name["email"].nullable is True |
| 142 | + assert by_name["age"].nullable is False |
| 143 | + assert by_name["user_id"].nullable is False |
| 144 | + |
| 145 | + def test_unqualified_table_uses_public(self, service, postgres_url): |
| 146 | + result = service.describe_table(postgres_url, "users") |
| 147 | + assert result.table == "public.users" |
| 148 | + |
| 149 | + def test_describes_table_in_non_default_schema(self, service, postgres_url): |
| 150 | + result = service.describe_table(postgres_url, "analytics.daily_metrics") |
| 151 | + assert {c.name for c in result.columns} == {"metric_date", "value"} |
| 152 | + |
| 153 | + def test_schema_argument_qualifies_table(self, service, postgres_url): |
| 154 | + result = service.describe_table( |
| 155 | + postgres_url, "daily_metrics", schema="analytics" |
| 156 | + ) |
| 157 | + assert result.table == "analytics.daily_metrics" |
| 158 | + |
| 159 | + def test_describes_a_view(self, service, postgres_url): |
| 160 | + result = service.describe_table(postgres_url, "public.active_users") |
| 161 | + assert [c.name for c in result.columns] == ["user_id", "email"] |
| 162 | + assert all(c.primary_key is False for c in result.columns) |
| 163 | + |
| 164 | + def test_unknown_table_raises(self, service, postgres_url): |
| 165 | + with pytest.raises(TableNotFoundError): |
| 166 | + service.describe_table(postgres_url, "public.does_not_exist") |
| 167 | + |
| 168 | + def test_unknown_schema_raises(self, service, postgres_url): |
| 169 | + with pytest.raises(TableNotFoundError): |
| 170 | + service.describe_table(postgres_url, "nosuchschema.users") |
| 171 | + |
| 172 | + |
| 173 | +@pytest.mark.integration |
| 174 | +class TestConnectionFailures: |
| 175 | + def test_bad_credentials_raise_connection_error(self, service, postgres_url): |
| 176 | + broken = postgres_url.replace("root:", "wronguser:") |
| 177 | + with pytest.raises(DatabaseConnectionError): |
| 178 | + service.list_schemas(broken) |
0 commit comments