| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229 |
- using Invercargill;
- using InvercargillSql;
- using InvercargillSql.Dialects;
- /**
- * Shared dual-backend test context for the ORM, migration and projection
- * suites.
- *
- * By default the context opens an in-memory SQLite database exactly like
- * the suites did before. When the PG_TEST_CONN environment variable is
- * set to a reachable PostgreSQL connection string, the context instead
- * opens PostgreSQL and isolates each call in a freshly created schema:
- *
- * - the schema is dropped (CASCADE) if it exists, then created;
- * - search_path is pointed at it for the connection's lifetime.
- *
- * Schemas are named with a monotonically increasing counter, so repeated
- * setup calls within one process never see each other's tables. The
- * PostgresDialect introspection already resolves information_schema rows
- * for the current schema only, keeping ORM schema discovery correct.
- */
- public class TestBackendContext : Object {
- private static int _schema_counter = 0;
- /** Connection string actually used for this context. */
- public string connection_string { get; private set; }
- /** Open connection (SQLite in-memory, or PostgreSQL in its schema). */
- public Connection connection { get; private set; }
- /** Dialect matching the backend. */
- public SqlDialect dialect { get; private set; }
- /** Backend identifier: "sqlite" or "postgres". */
- public string backend { get; private set; }
- /** Name of the isolating PostgreSQL schema, or null for SQLite. */
- public string? schema_name { get; private set; }
- public TestBackendContext() throws SqlError {
- string? pg = GLib.Environment.get_variable("PG_TEST_CONN");
- if (pg != null && pg.length > 0) {
- backend = "postgres";
- connection_string = pg;
- connection = ConnectionFactory.create_and_open(pg);
- dialect = new PostgresDialect();
- _schema_counter++;
- schema_name = "t_%d".printf(_schema_counter);
- connection.execute("DROP SCHEMA IF EXISTS %s CASCADE".printf(schema_name));
- connection.execute("CREATE SCHEMA %s".printf(schema_name));
- connection.execute("SET search_path TO %s".printf(schema_name));
- } else {
- backend = "sqlite";
- connection_string = "sqlite::memory:";
- connection = ConnectionFactory.create_and_open("sqlite::memory:");
- dialect = new SqliteDialect();
- schema_name = null;
- }
- }
- /**
- * True when running against PostgreSQL. Tests use this to skip
- * backend-specific assertions (e.g. exact SQL text comparisons).
- */
- public bool is_postgres() {
- return backend == "postgres";
- }
- /**
- * Executes a CREATE TABLE statement in a backend-portable way.
- *
- * The DDL is written in the suites' existing SQLite-flavoured style
- * (INTEGER PRIMARY KEY AUTOINCREMENT, TEXT, REAL, INTEGER, BOOLEAN,
- * BLOB). On PostgreSQL the text is translated to equivalent native
- * DDL: identity columns, BIGINT/DOUBLE PRECISION/BYTEA types and the
- * same column names, so entity mappers introspect identically.
- */
- public void create_table(string sqlite_ddl, string[] boolean_columns = {},
- string[] datetime_columns = {}) throws SqlError {
- if (backend == "sqlite") {
- connection.execute(sqlite_ddl);
- return;
- }
- connection.execute(translate_ddl(sqlite_ddl, boolean_columns, datetime_columns));
- }
- /**
- * Backend-portable table existence check used where the suites
- * previously queried sqlite_master directly.
- */
- public bool table_exists(string table_name) throws SqlError {
- if (backend == "sqlite") {
- var cmd = connection.create_command(
- "SELECT name FROM sqlite_master WHERE type='table' AND name = :name"
- );
- cmd.with_parameter<string>("name", table_name);
- return cmd.execute_query().any();
- }
- var cmd = connection.create_command(
- "SELECT table_name FROM information_schema.tables WHERE table_name = :name AND table_schema = :schema"
- );
- cmd.with_parameter<string>("name", table_name);
- cmd.with_parameter<string>("schema", schema_name);
- return cmd.execute_query().any();
- }
- /**
- * Drops a table if it exists (portable DROP ... IF EXISTS).
- */
- public void drop_table(string table_name) throws SqlError {
- connection.execute("DROP TABLE IF EXISTS %s".printf(table_name));
- }
- /**
- * Translates SQLite-flavoured CREATE TABLE DDL to PostgreSQL.
- *
- * Test-only helper: production code goes through MigrationRunner and
- * the dialect's create_table_sql. Recognises the shapes used by the
- * test suites (one column per line, closing paren on its own line).
- */
- private string translate_ddl(string ddl, string[] boolean_columns = {},
- string[] datetime_columns = {}) {
- var sb = new StringBuilder();
- string[] lines = ddl.split("\n");
- var columns = new string[0];
- string header = "";
- foreach (string raw_line in lines) {
- string line = raw_line.strip();
- if (line.length == 0) {
- continue;
- }
- if (line.has_prefix("--")) {
- continue;
- }
- string lower = line.down();
- if (lower.has_prefix("create table")) {
- // The header may already open the column list, e.g.
- // "CREATE TABLE users ("; strip the trailing "(" so the
- // generator below can add its own.
- if (line.has_suffix("(")) {
- header = line.substring(0, line.length - 1).strip();
- } else {
- header = line;
- }
- continue;
- }
- if (line.has_prefix(")")) {
- continue;
- }
- if (line.has_suffix(",")) {
- line = line.substring(0, line.length - 1).strip();
- }
- columns += translate_column_line(line, boolean_columns, datetime_columns);
- }
- sb.append(header);
- sb.append(" (\n");
- for (int i = 0; i < columns.length; i++) {
- sb.append(" ");
- sb.append(columns[i]);
- if (i < columns.length - 1) {
- sb.append(",");
- }
- sb.append("\n");
- }
- sb.append(")");
- return sb.str;
- }
- private string translate_column_line(string line, string[] boolean_columns = {},
- string[] datetime_columns = {}) {
- string upper = line.up();
- if ("AUTOINCREMENT" in upper) {
- string col_name = line.split(" ")[0];
- return "%s BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY".printf(col_name);
- }
- // SQLite stores booleans as INTEGER 0/1; the ORM binds bools as
- // PostgresBoolElement ("t"/"f"), which a BIGINT column rejects.
- // Columns named by the caller become native BOOLEAN instead, and
- // PostgresBoolElement round-trips through them.
- string col_name = line.split(" ")[0];
- foreach (string boolean_column in boolean_columns) {
- if (col_name == boolean_column) {
- string rest = line.substring(col_name.length).strip();
- rest = replace_type(rest, "INTEGER", "BOOLEAN");
- return "%s %s".printf(col_name, rest);
- }
- }
- // SQLite has no native date/time type, so the suites store
- // DateTime as INTEGER (Unix epoch). PostgreSQL does have one, so
- // hinted columns become TIMESTAMPTZ and DateTime binds as ISO 8601
- // text, which the adapter parses back to DateTime on read.
- foreach (string datetime_column in datetime_columns) {
- if (col_name == datetime_column) {
- string rest = line.substring(col_name.length).strip();
- rest = replace_type(rest, "INTEGER", "TIMESTAMPTZ");
- return "%s %s".printf(col_name, rest);
- }
- }
- string result = line;
- result = replace_type(result, " INTEGER", " BIGINT");
- result = replace_type(result, " REAL", " DOUBLE PRECISION");
- result = replace_type(result, " BLOB", " BYTEA");
- return result;
- }
- private string replace_type(string line, string from, string to) {
- string result = line;
- string up = result.up();
- int idx = up.index_of(from.up());
- while (idx >= 0) {
- bool word_start = idx == 0 || !is_ident_char(up[idx - 1]);
- int end = idx + from.length;
- bool word_end = end >= up.length || !is_ident_char(up[end]);
- if (word_start && word_end) {
- result = result.substring(0, idx) + to + result.substring(end);
- up = result.up();
- idx = up.index_of(from.up(), idx + to.length);
- } else {
- idx = up.index_of(from.up(), idx + 1);
- }
- }
- return result;
- }
- private bool is_ident_char(char c) {
- return (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z')
- || (c >= '0' && c <= '9') || c == '_';
- }
- }
|