# Phase 6: Unified Query API ## Overview This phase refactors the query system to provide a unified API for both entity queries and projection queries. The current `Query` class will become an abstract base class, with `EntityQuery` handling entity-specific logic and `ProjectionQuery` adapted to extend the same base. ## Goals 1. Create an abstract `Query` base class with shared state and common methods 2. Extract current `Query` functionality into `EntityQuery` 3. Adapt `ProjectionQuery` to extend `Query` 4. Update `OrmSession.query()` to return the appropriate query type based on type registration 5. Standardize return types on `ImmutableLot` (which extends `Lot` extends `Enumerable`) ## Current Architecture ``` ┌─────────────────┐ ┌─────────────────────────┐ │ Query │ │ ProjectionQuery │ │ (concrete) │ │ (concrete) │ ├─────────────────┤ ├─────────────────────────┤ │ _session │ │ _query_session │ │ _filter: Expr? │ │ _query_definition │ │ _orderings │ │ _query_sql_builder │ │ _limit: int? │ │ _where_clauses │ │ _offset: int? │ │ _order_by_clauses │ ├─────────────────┤ │ _limit_value: int64? │ │ where() │ │ _offset_value: int64? │ │ where_expr() │ │ _use_or │ │ order_by() │ ├─────────────────────────┤ │ order_by_desc() │ │ where() │ │ limit() │ │ or_where() │ │ offset() │ │ order_by() │ │ materialise() │ │ order_by_desc() │ │ materialise_ │ │ limit() │ │ async() │ │ offset() │ │ first() │ │ materialise() │ │ first_async() │ │ materialise_async() │ └─────────────────┘ │ first() │ │ first_async() │ │ to_sql() │ └─────────────────────────┘ ``` ## Proposed Architecture ``` ┌───────────────────────────┐ │ Query (abstract) │ ├───────────────────────────┤ │ #_session: OrmSession │ │ #_orderings: Vector<...> │ │ #_limit: int64? │ │ #_offset: int64? │ │ #_use_or: bool │ ├───────────────────────────┤ │ where(expr): Query │ │ or_where(expr): Query │ │ order_by(expr): Query │ │ order_by_desc(): Query │ │ limit(count): Query │ │ offset(count): Query │ │ materialise(): Immutable │ │ Lot │ │ materialise_async(): ... │ │ first(): T? │ │ first_async(): T? │ │ to_sql(): string │ │ where_expr(e): Query │ ├───────────────────────────┤ │ #build_sql(): string │ │ #get_combined_where() │ └─────────────┬─────────────┘ │ ┌─────────────────┴─────────────────┐ │ │ ┌───────────────┴───────────────┐ ┌───────────────┴───────────────┐ │ EntityQuery (concrete) │ │ ProjectionQuery (concrete) │ ├───────────────────────────────┤ ├───────────────────────────────┤ │ -_filter: Expression? │ │ -_query_definition │ │ -_where_clauses: Vector<...> │ │ -_query_sql_builder │ │ │ │ -_where_clauses: Vector<...> │ ├───────────────────────────────┤ ├───────────────────────────────┤ │ #build_sql(): string │ │ #build_sql(): string │ │ #get_combined_where() │ │ #get_combined_where() │ │ +where_expr(e): EntityQuery│ │ +where_expr(): throws error │ └───────────────────────────────┘ └───────────────────────────────┘ ``` ## Implementation Details ### 1. Abstract Query Base Class **File:** `src/orm/query.vala` ```vala using Invercargill.DataStructures; using Invercargill.Expressions; namespace InvercargillSql.Orm { /** * Abstract base class for all queries. * * Query provides a fluent interface for building database queries. * Subclasses implement specific query execution strategies for entities * and projections. */ public abstract class Query : Object { // Shared state - protected for subclass access protected OrmSession _session; protected Vector _orderings; protected int64? _limit; protected int64? _offset; protected bool _use_or; protected Vector _where_clauses; /** * Creates a new Query for the given session. */ protected Query(OrmSession session) { _session = session; _orderings = new Vector(); _where_clauses = new Vector(); _limit = null; _offset = null; _use_or = false; } // === Fluent query builders === public virtual Query where(string expression) { _where_clauses.add(expression); return this; } public virtual Query or_where(string expression) { _use_or = true; _where_clauses.add(expression); return this; } public abstract Query where_expr(Expression expression); public Query order_by(string expression) { _orderings.add(new OrderByClause(expression, false)); return this; } public Query order_by_desc(string expression) { _orderings.add(new OrderByClause(expression, true)); return this; } public Query limit(int64 count) { _limit = count; return this; } public Query offset(int64 count) { _offset = count; return this; } // === Execution methods === /** * Executes the query and returns results. */ public abstract Invercargill.ImmutableLot materialise() throws SqlError; /** * Executes the query asynchronously. */ public abstract async Invercargill.ImmutableLot materialise_async() throws SqlError; /** * Returns the first result or null. */ public virtual T? first() throws SqlError { _limit = 1; var results = materialise(); if (results.length > 0) { return results.first(); } return null; } /** * Returns the first result asynchronously. */ public virtual async T? first_async() throws SqlError { _limit = 1; var results = yield materialise_async(); if (results.length > 0) { return results.first(); } return null; } /** * Returns the SQL for this query. */ public abstract string to_sql(); // === Protected helpers === protected string? get_combined_where() { if (_where_clauses.length == 0) { return null; } if (_where_clauses.length == 1) { return _where_clauses.get(0); } var combined = new StringBuilder(); string connector = _use_or ? " || " : " && "; bool first = true; foreach (var clause in _where_clauses) { if (!first) { combined.append(connector); } combined.append("("); combined.append(clause); combined.append(")"); first = false; } return combined.str; } } } ``` ### 2. EntityQuery Implementation **File:** `src/orm/entity-query.vala` (new file) ```vala using Invercargill.DataStructures; using Invercargill.Expressions; using InvercargillSql.Dialects; using InvercargillSql.Expressions; namespace InvercargillSql.Orm { /** * Query implementation for entity types. * * EntityQuery handles queries for registered entity types, * using EntityMapper for materialization and ExpressionToSqlVisitor * for WHERE clause generation. */ public class EntityQuery : Query { private Expression? _filter; internal EntityQuery(OrmSession session) { base(session); _filter = null; } public override Query where(string expression) { // Parse string to Expression and store _filter = ExpressionParser.parse(expression); _where_clauses.add(expression); return this; } public override Query or_where(string expression) { // For EntityQuery, combine with existing filter using OR var new_filter = ExpressionParser.parse(expression); if (_filter != null) { _filter = new BinaryExpression( _filter, new_filter, BinaryOperator.OR ); } else { _filter = new_filter; } _use_or = true; _where_clauses.add(expression); return this; } public override Query where_expr(Expression expression) { _filter = expression; return this; } public override Invercargill.ImmutableLot materialise() throws SqlError { var mapper = _session.get_mapper(); var dialect = _session.get_dialect(); // Build SQL var sql = build_select_sql(mapper, dialect); var command = _session.get_connection().create_command(sql); // Add parameters if filter exists if (_filter != null) { var visitor = new ExpressionToSqlVisitor(dialect, mapper); _filter.accept(visitor); var parameters = visitor.get_parameters(); var param_names = visitor.get_parameter_names(); var param_array = parameters.to_array(); var name_array = param_names.to_array(); for (int i = 0; i < param_array.length && i < name_array.length; i++) { command.with_parameter(name_array[i], param_array[i]); } } // Execute and materialize var results = command.execute_query(); var entities = new Vector(); foreach (var row in results) { try { var entity = mapper.materialise(row); entities.add(entity); } catch (Error e) { throw new SqlError.GENERAL_ERROR( "Failed to materialize entity: %s".printf(e.message) ); } } return entities.to_immutable_buffer(); } public override async Invercargill.ImmutableLot materialise_async() throws SqlError { var mapper = _session.get_mapper(); var dialect = _session.get_dialect(); var sql = build_select_sql(mapper, dialect); var command = _session.get_connection().create_command(sql); if (_filter != null) { var visitor = new ExpressionToSqlVisitor(dialect, mapper); _filter.accept(visitor); var parameters = visitor.get_parameters(); var param_names = visitor.get_parameter_names(); var param_array = parameters.to_array(); var name_array = param_names.to_array(); for (int i = 0; i < param_array.length && i < name_array.length; i++) { command.with_parameter(name_array[i], param_array[i]); } } var results = yield command.execute_query_async(); var entities = new Vector(); foreach (var row in results) { try { var entity = mapper.materialise(row); entities.add(entity); } catch (Error e) { throw new SqlError.GENERAL_ERROR( "Failed to materialize entity: %s".printf(e.message) ); } } return entities.to_immutable_buffer(); } public override string to_sql() { try { var mapper = _session.get_mapper(); var dialect = _session.get_dialect(); return build_select_sql(mapper, dialect); } catch (SqlError e) { return "-- Error building SQL: %s".printf(e.message); } } private string build_select_sql(EntityMapper mapper, SqlDialect dialect) { var sql = new StringBuilder(); sql.append("SELECT * FROM "); sql.append(mapper.table_name); // WHERE clause if (_filter != null) { var visitor = new ExpressionToSqlVisitor(dialect, mapper); _filter.accept(visitor); sql.append(" WHERE "); sql.append(visitor.get_sql()); } // ORDER BY if (_orderings.length > 0) { sql.append(" ORDER BY "); bool first = true; foreach (var ordering in _orderings) { if (!first) { sql.append(", "); } sql.append(ordering.expression); if (ordering.descending) { sql.append(" DESC"); } first = false; } } // LIMIT/OFFSET if (_limit != null) { sql.append_printf(" LIMIT %lld", _limit); } if (_offset != null) { sql.append_printf(" OFFSET %lld", _offset); } return sql.str; } } } ``` ### 3. Updated ProjectionQuery **File:** `src/orm/projections/projection-query.vala` Key changes: - Extend `Query` instead of `Object` - Change `_limit_value`/`_offset_value` to use base class `_limit`/`_offset` - Change `_order_by_clauses` to use base class `_orderings` - Change `_where_clauses` to use base class `_where_clauses` - Change `_use_or` to use base class `_use_or` - Implement `where_expr()` to throw an error (projections use string-based where) - Change return types to `ImmutableLot` ```vala public class ProjectionQuery : Query { private ProjectionDefinition _query_definition; private ProjectionSqlBuilder _query_sql_builder; internal ProjectionQuery( OrmSession session, ProjectionDefinition definition, ProjectionSqlBuilder sql_builder ) { base(session); // Initialize base class _query_definition = definition; _query_sql_builder = sql_builder; } // where() and or_where() use base class implementation public override Query where_expr(Expression expression) { // Projections don't support Expression-based where clauses // because they need to translate through the projection definition throw new SqlError.GENERAL_ERROR( "ProjectionQuery does not support where_expr(). Use where() with a string expression instead." ); } public override Invercargill.ImmutableLot materialise() throws SqlError { string? where_expr = get_combined_where(); BuiltQuery built = _query_sql_builder.build_with_split( where_expr, _orderings, // Use base class field _limit, // Use base class field _offset // Use base class field ); string sql = built.sql; var connection = _session.get_connection(); // Use base class session var command = connection.create_command(sql); var results = command.execute_query(); var mapper = new ProjectionMapper(_query_definition); try { var vector = mapper.map_all(results); return vector.to_immutable_buffer(); } catch (ProjectionError e) { throw new SqlError.GENERAL_ERROR( @"Failed to materialize projection: $(e.message)" ); } } // Similar changes for materialise_async(), first(), first_async() public override string to_sql() { string? where_expr = get_combined_where(); BuiltQuery built = _query_sql_builder.build_with_split( where_expr, _orderings, _limit, _offset ); return built.sql; } } ``` ### 4. Updated OrmSession **File:** `src/orm/orm-session.vala` ```vala /** * Creates a new query for type T. * * Returns EntityQuery if T is a registered entity type. * Returns ProjectionQuery if T is a registered projection type. * * @return A new Query instance appropriate for type T * @throws SqlError if T is neither a registered entity nor projection */ public Query query() throws SqlError { var type = typeof(T); // Check if it's a registered entity if (_mappers.contains(type)) { return new EntityQuery(this); } // Check if it's a registered projection var projection_def = projection_registry.get(type); if (projection_def != null) { var sql_builder = new ProjectionSqlBuilder(projection_def, _dialect); return new ProjectionQuery(this, projection_def, sql_builder); } throw new SqlError.GENERAL_ERROR( "Type %s is not registered as an entity or projection".printf(type.name()) ); } /** * Gets the SQL dialect for internal use. */ internal SqlDialect get_dialect() { return _dialect; } ``` ### 5. OrmSession API Changes The following methods become internal or are removed: | Method | Change | |--------|--------| | `execute_query(Query)` | Remove - logic moved to EntityQuery | | `execute_query_async(Query)` | Remove - logic moved to EntityQuery | | `query_projection()` | Deprecate - use `query()` instead | | `get_connection()` | Keep as internal | | `get_dialect()` | Add as internal | ## Migration Guide ### For Entity Queries Before: ```vala var users = session.query() .where("age > 18") .materialise(); ``` After: ```vala // Same API, but now returns ImmutableLot var users = session.query() .where("age > 18") .materialise(); ``` ### For Projection Queries Before: ```vala var stats = session.query_projection() .where("user_id > 100") .materialise(); ``` After: ```vala // Use query() instead of query_projection() var stats = session.query() .where("user_id > 100") .materialise(); ``` ### New Unified Features Both query types now support: ```vala // or_where() now available on both var results = session.query() .where("age > 18") .or_where("name == 'Admin'") .materialise(); // to_sql() now available on both string sql = session.query() .where("age > 18") .to_sql(); ``` ## Implementation Checklist - [ ] Create abstract `Query` base class in `src/orm/query.vala` - [ ] Create `EntityQuery` in `src/orm/entity-query.vala` - [ ] Update `ProjectionQuery` to extend `Query` - [ ] Update `OrmSession.query()` to dispatch to correct type - [ ] Add `get_dialect()` method to OrmSession - [ ] Remove `execute_query()` and `execute_query_async()` from OrmSession - [ ] Deprecate `query_projection()` (keep for backward compatibility) - [ ] Update `src/meson.build` to include new `entity-query.vala` - [ ] Update tests to use unified API - [ ] Update documentation ## Testing Strategy 1. **Unit Tests for EntityQuery** - Test `where()`, `or_where()`, `where_expr()` - Test `order_by()`, `order_by_desc()` - Test `limit()`, `offset()` - Test `materialise()`, `materialise_async()` - Test `first()`, `first_async()` - Test `to_sql()` 2. **Unit Tests for ProjectionQuery** - Same tests as EntityQuery - Test that `where_expr()` throws appropriate error 3. **Integration Tests for OrmSession.query()** - Test dispatch to EntityQuery for registered entities - Test dispatch to ProjectionQuery for registered projections - Test error for unregistered types 4. **Backward Compatibility Tests** - Ensure existing entity query tests pass - Ensure existing projection query tests pass ## Risks and Mitigations | Risk | Mitigation | |------|------------| | Breaking existing code | Keep `query_projection()` as deprecated method | | Type inference issues | Ensure return type is `Query` not concrete type | | ImmutableLot conversion | Use `to_immutable_buffer()` extension method | | Expression handling in EntityQuery | Reuse existing ExpressionToSqlVisitor | ## Files Changed | File | Change | |------|--------| | `src/orm/query.vala` | Convert to abstract class | | `src/orm/entity-query.vala` | New file | | `src/orm/projections/projection-query.vala` | Extend Query | | `src/orm/orm-session.vala` | Update query() dispatch | | `src/meson.build` | Add entity-query.vala | | `src/tests/orm-test.vala` | Update tests | | `src/tests/projection-test.vala` | Update tests |