test-backend.vala 9.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229
  1. using Invercargill;
  2. using InvercargillSql;
  3. using InvercargillSql.Dialects;
  4. /**
  5. * Shared dual-backend test context for the ORM, migration and projection
  6. * suites.
  7. *
  8. * By default the context opens an in-memory SQLite database exactly like
  9. * the suites did before. When the PG_TEST_CONN environment variable is
  10. * set to a reachable PostgreSQL connection string, the context instead
  11. * opens PostgreSQL and isolates each call in a freshly created schema:
  12. *
  13. * - the schema is dropped (CASCADE) if it exists, then created;
  14. * - search_path is pointed at it for the connection's lifetime.
  15. *
  16. * Schemas are named with a monotonically increasing counter, so repeated
  17. * setup calls within one process never see each other's tables. The
  18. * PostgresDialect introspection already resolves information_schema rows
  19. * for the current schema only, keeping ORM schema discovery correct.
  20. */
  21. public class TestBackendContext : Object {
  22. private static int _schema_counter = 0;
  23. /** Connection string actually used for this context. */
  24. public string connection_string { get; private set; }
  25. /** Open connection (SQLite in-memory, or PostgreSQL in its schema). */
  26. public Connection connection { get; private set; }
  27. /** Dialect matching the backend. */
  28. public SqlDialect dialect { get; private set; }
  29. /** Backend identifier: "sqlite" or "postgres". */
  30. public string backend { get; private set; }
  31. /** Name of the isolating PostgreSQL schema, or null for SQLite. */
  32. public string? schema_name { get; private set; }
  33. public TestBackendContext() throws SqlError {
  34. string? pg = GLib.Environment.get_variable("PG_TEST_CONN");
  35. if (pg != null && pg.length > 0) {
  36. backend = "postgres";
  37. connection_string = pg;
  38. connection = ConnectionFactory.create_and_open(pg);
  39. dialect = new PostgresDialect();
  40. _schema_counter++;
  41. schema_name = "t_%d".printf(_schema_counter);
  42. connection.execute("DROP SCHEMA IF EXISTS %s CASCADE".printf(schema_name));
  43. connection.execute("CREATE SCHEMA %s".printf(schema_name));
  44. connection.execute("SET search_path TO %s".printf(schema_name));
  45. } else {
  46. backend = "sqlite";
  47. connection_string = "sqlite::memory:";
  48. connection = ConnectionFactory.create_and_open("sqlite::memory:");
  49. dialect = new SqliteDialect();
  50. schema_name = null;
  51. }
  52. }
  53. /**
  54. * True when running against PostgreSQL. Tests use this to skip
  55. * backend-specific assertions (e.g. exact SQL text comparisons).
  56. */
  57. public bool is_postgres() {
  58. return backend == "postgres";
  59. }
  60. /**
  61. * Executes a CREATE TABLE statement in a backend-portable way.
  62. *
  63. * The DDL is written in the suites' existing SQLite-flavoured style
  64. * (INTEGER PRIMARY KEY AUTOINCREMENT, TEXT, REAL, INTEGER, BOOLEAN,
  65. * BLOB). On PostgreSQL the text is translated to equivalent native
  66. * DDL: identity columns, BIGINT/DOUBLE PRECISION/BYTEA types and the
  67. * same column names, so entity mappers introspect identically.
  68. */
  69. public void create_table(string sqlite_ddl, string[] boolean_columns = {},
  70. string[] datetime_columns = {}) throws SqlError {
  71. if (backend == "sqlite") {
  72. connection.execute(sqlite_ddl);
  73. return;
  74. }
  75. connection.execute(translate_ddl(sqlite_ddl, boolean_columns, datetime_columns));
  76. }
  77. /**
  78. * Backend-portable table existence check used where the suites
  79. * previously queried sqlite_master directly.
  80. */
  81. public bool table_exists(string table_name) throws SqlError {
  82. if (backend == "sqlite") {
  83. var cmd = connection.create_command(
  84. "SELECT name FROM sqlite_master WHERE type='table' AND name = :name"
  85. );
  86. cmd.with_parameter<string>("name", table_name);
  87. return cmd.execute_query().any();
  88. }
  89. var cmd = connection.create_command(
  90. "SELECT table_name FROM information_schema.tables WHERE table_name = :name AND table_schema = :schema"
  91. );
  92. cmd.with_parameter<string>("name", table_name);
  93. cmd.with_parameter<string>("schema", schema_name);
  94. return cmd.execute_query().any();
  95. }
  96. /**
  97. * Drops a table if it exists (portable DROP ... IF EXISTS).
  98. */
  99. public void drop_table(string table_name) throws SqlError {
  100. connection.execute("DROP TABLE IF EXISTS %s".printf(table_name));
  101. }
  102. /**
  103. * Translates SQLite-flavoured CREATE TABLE DDL to PostgreSQL.
  104. *
  105. * Test-only helper: production code goes through MigrationRunner and
  106. * the dialect's create_table_sql. Recognises the shapes used by the
  107. * test suites (one column per line, closing paren on its own line).
  108. */
  109. private string translate_ddl(string ddl, string[] boolean_columns = {},
  110. string[] datetime_columns = {}) {
  111. var sb = new StringBuilder();
  112. string[] lines = ddl.split("\n");
  113. var columns = new string[0];
  114. string header = "";
  115. foreach (string raw_line in lines) {
  116. string line = raw_line.strip();
  117. if (line.length == 0) {
  118. continue;
  119. }
  120. if (line.has_prefix("--")) {
  121. continue;
  122. }
  123. string lower = line.down();
  124. if (lower.has_prefix("create table")) {
  125. // The header may already open the column list, e.g.
  126. // "CREATE TABLE users ("; strip the trailing "(" so the
  127. // generator below can add its own.
  128. if (line.has_suffix("(")) {
  129. header = line.substring(0, line.length - 1).strip();
  130. } else {
  131. header = line;
  132. }
  133. continue;
  134. }
  135. if (line.has_prefix(")")) {
  136. continue;
  137. }
  138. if (line.has_suffix(",")) {
  139. line = line.substring(0, line.length - 1).strip();
  140. }
  141. columns += translate_column_line(line, boolean_columns, datetime_columns);
  142. }
  143. sb.append(header);
  144. sb.append(" (\n");
  145. for (int i = 0; i < columns.length; i++) {
  146. sb.append(" ");
  147. sb.append(columns[i]);
  148. if (i < columns.length - 1) {
  149. sb.append(",");
  150. }
  151. sb.append("\n");
  152. }
  153. sb.append(")");
  154. return sb.str;
  155. }
  156. private string translate_column_line(string line, string[] boolean_columns = {},
  157. string[] datetime_columns = {}) {
  158. string upper = line.up();
  159. if ("AUTOINCREMENT" in upper) {
  160. string col_name = line.split(" ")[0];
  161. return "%s BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY".printf(col_name);
  162. }
  163. // SQLite stores booleans as INTEGER 0/1; the ORM binds bools as
  164. // PostgresBoolElement ("t"/"f"), which a BIGINT column rejects.
  165. // Columns named by the caller become native BOOLEAN instead, and
  166. // PostgresBoolElement round-trips through them.
  167. string col_name = line.split(" ")[0];
  168. foreach (string boolean_column in boolean_columns) {
  169. if (col_name == boolean_column) {
  170. string rest = line.substring(col_name.length).strip();
  171. rest = replace_type(rest, "INTEGER", "BOOLEAN");
  172. return "%s %s".printf(col_name, rest);
  173. }
  174. }
  175. // SQLite has no native date/time type, so the suites store
  176. // DateTime as INTEGER (Unix epoch). PostgreSQL does have one, so
  177. // hinted columns become TIMESTAMPTZ and DateTime binds as ISO 8601
  178. // text, which the adapter parses back to DateTime on read.
  179. foreach (string datetime_column in datetime_columns) {
  180. if (col_name == datetime_column) {
  181. string rest = line.substring(col_name.length).strip();
  182. rest = replace_type(rest, "INTEGER", "TIMESTAMPTZ");
  183. return "%s %s".printf(col_name, rest);
  184. }
  185. }
  186. string result = line;
  187. result = replace_type(result, " INTEGER", " BIGINT");
  188. result = replace_type(result, " REAL", " DOUBLE PRECISION");
  189. result = replace_type(result, " BLOB", " BYTEA");
  190. return result;
  191. }
  192. private string replace_type(string line, string from, string to) {
  193. string result = line;
  194. string up = result.up();
  195. int idx = up.index_of(from.up());
  196. while (idx >= 0) {
  197. bool word_start = idx == 0 || !is_ident_char(up[idx - 1]);
  198. int end = idx + from.length;
  199. bool word_end = end >= up.length || !is_ident_char(up[end]);
  200. if (word_start && word_end) {
  201. result = result.substring(0, idx) + to + result.substring(end);
  202. up = result.up();
  203. idx = up.index_of(from.up(), idx + to.length);
  204. } else {
  205. idx = up.index_of(from.up(), idx + 1);
  206. }
  207. }
  208. return result;
  209. }
  210. private bool is_ident_char(char c) {
  211. return (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z')
  212. || (c >= '0' && c <= '9') || c == '_';
  213. }
  214. }