Repository navigation
Spectro 3.0 closes most of the gap with Ecto for PostgreSQL. Upgrading from 2.x: give @Column properties a DatabaseValue type; properties of other types were silently neither stored nor decoded. Add staleRecord to exhaustive switches over SpectroError, custom to those over FieldType, and the new column types to those over MigrationColumnType. Call SchemaMapper.extractData with try. A non-NULL value a field cannot decode, such as an unknown enum label, now throws SpectroError.resultDecodingFailed instead of leaving the field's default.
- Add
orderBy(sql:_:)toQueryandTupleQuery, to sort by anSQLFragment. Fragment and field sorts keep call order, and the fragment's parameters bind after the filters'.count(), aggregates, and direct bulk writes drop the ordering with its parameters. - Add
Repo.executeRawQuery(_:), like Ecto'sRepo.query, to run a statement written as anSQLFragment, with its placeholders numbered. - Add
Query.distinct()andQuery.distinct(_:)forDISTINCTandDISTINCT ON, andTupleQuery.distinct(). DISTINCT ON columns the ordering does not start with are sorted first, as Ecto does.count()and aggregates count each distinct row once. - Add subqueries:
in(_:)andnotIn(_:)take a single-columnTupleQuery, andQueryCondition.exists(_:)takes aQuery, correlated through the outer query's fields. AddQuery.exists(), like Ecto'sRepo.exists?. - Compare columns with columns:
==,!=,<,<=,>, and>=betweenQueryFields;==and!=also compare a nullable column with a nonnullable one. - Add
union,unionAll,intersect,intersectAll,except, andexceptAll. Each query keeps its filters, joins, ordering, and limit; clauses added to the result apply to the combined rows. Queries with a row lock orselectFieldsthrowinvalidQuery. - Add
Query.recursive(_:), aWITH RECURSIVEquery that follows rows from the query's rows, such as a tree's descendants or ancestors. It skips soft-deleted rows and ends on cycles. - Select SQL expressions with
QueryBuilder.expression(_:as:), such as aggregates and window functions, alongside columns:selecttakes anySelection, aQueryFieldof aDatabaseValuetype or anSQLExpression. A projection of a query withgroupByandhavingis now grouped, one result per group, and itscount()counts the groups; the grouping was ignored. - Declare nullable foreign keys with
@ForeignKey var editorId: UUID?.ForeignKeynow takes aForeignKeyValue: aPrimaryKeyType, or anOptionalof one. A nil key stores NULL and preloads no record. - Add
Changeset.castAssocfor has-many and has-one relationships, like Ecto'scast_assoc. Nested params become changesets of the loaded or new related records, which inserting or updating the changeset writes with their foreign key in one transaction, returning the record with the relationship loaded. Related errors join as"posts[1].title", andOnReplacedecides what happens to loaded records the params leave out; has-one params carrying another key replace the loaded record. Inside a caller's transaction, the writes run in a savepoint, and errors keep their type, such asstaleRecord, instead oftransactionFailed.Changeset.errorshas an internal setter instead of a private one. - Cast many-to-many relationships with
castAssoc: it writes the related records and inserts their missing join rows, andonReplace: .deletedeletes only the join rows of the records left out, as in Ecto. A relationship of another kind gets the error "is not a has-many or many-to-many relationship". - Add
Changeset.putAssocfor has-many, has-one, and many-to-many relationships, like Ecto'sput_assoc. It sets a relationship to records already stored, inserting the missing join rows or setting the foreign key of records that belong elsewhere, or to changesets, which insert or update related records in the same transaction. A join row that exists is not inserted again. A latercastAssocorputAssocof the same relationship replaces an earlier one, with its errors, as in Ecto. - Select five or more columns and expressions:
selecttakes a closure returning any number ofSelections and returns Swift tuples, such as(UUID, String, String, Int, Bool). Selections of one to four columns still return values andTuple2toTuple4. - Add
Query.joinLateral(as:_:)andleftJoinLateral(as:_:)forJOIN LATERAL, like Ecto's lateral joins: the rows a subquery selects for each row, such as each author's latest posts. The subquery refers to the row through the closure's builder; decode the joined rows withexecuteJoin(with:as:). The subquery keeps its own soft-delete filter, and cannot lock rows. - Add
Query.qualify(_:), which keeps the rows for which a condition that may use window functions holds, as SQL'sQUALIFYdoes, such as the top posts of each author. The window functions see the rows left by the query's distinct, limit, and offset; clauses added after it apply to the remaining rows. - Add
@EmbeddedSchema, like Ecto'sembedded_schema: a schema without a table, which may omit@ID, conforming toJSONBValueand stored in ajsonbcolumn of another schema, or a list of them in ajsonb[]column. Properties are stored under their column names, withnilas null, and a missing key keeps the property's default.castaccepts only values of the type for such a field. On its own, an embedded schema validates input without a table, such as a form. - Add
Changeset.castEmbedandputEmbedfor an optional, a nonoptional, and a list of embedded schemas, like Ecto'scast_embedandput_embed.castEmbedreads the field's nested params from the params given tocast, now kept asChangeset.params: without them, the field is left as it is, andnilorNSNullremoves an optional value. The values are changes of the field, written with the record, and their errors join as"address.city"and"phones[1].number". In a list, params carrying the primary key of one of the record's values update it, others add values, and the values left out are removed. A later cast or put of the field replaces an earlier one, with its errors. - The
@Schemamacro extends a type nested in another one, and skips conformances the type already declares, such asEncodable. Both failed to compile. Changeset.applyToInstance()applies the changes to@Schemaand@EmbeddedSchematypes, like Ecto'sapply_action; it returned their data unchanged. The result does not keep loaded relationships.- Add
Changeset.prepareChanges(_:), like Ecto'sprepare_changes: a function that runs in the write's transaction just before it, and is skipped for an invalid changeset. - Name changeset fields with key paths:
cast(_:params:permitting:),validateRequired(_:),validateLength,validateFormat,validateNumber,validateCustom, and the constraint declarations take key paths;validateInclusionandvalidateExclusioncompare values of the field's type;getField,getChange, andputChangeare typed;errors(on:)reads a field's errors. - Add
Changeset.optimisticLock(_:), like Ecto'soptimistic_lock. An update increments the integer version field and matches the row only while it holds the loaded version; otherwise it throws the newSpectroError.staleRecord. A version cast from params, such as a form's hidden field, wins over the loaded one, as in Ecto. AddstaleRecordto exhaustiveSpectroErrorswitches. - Extend the migration DSL.
AlterTableremoves columns, changes types, nullability, and defaults, and adds check, unique, and foreign-key constraints, each with an automatic rollback;dropConstraintbelongs in an explicit branch. AddRenameTable,CreateEnum,DropIndex, andCreateIndex.concurrently(), and the column typessmallint,real,doublePrecision,date,timestamp,bytea,json,array(_:), andnamed(_:schema:). Add the new cases to exhaustiveMigrationColumnTypeswitches. - Run a migration outside a transaction, for
CREATE INDEX CONCURRENTLY, like Ecto's@disable_ddl_transaction: setMigration.runsInTransactiontofalse, or start a SQL file with-- migrate:up transaction:false, as in dbmate.PreparedMigrationgainsrunsInTransaction. - Add
transaction(isolation:_:)toGenericDatabaseRepo,Spectro, andDatabaseConnection, withTransactionIsolationlevelsreadCommitted,repeatableRead, andserializable. - Add
insertAll(_:onConflict:includePrimaryKey:)andinsert(_:onConflict:includePrimaryKey:)withConflictAction.nothing(on:)and.replace(on:columns:), like Ecto'son_conflict. They return the records inserted or updated. - Add
Repo.reload(_:)for one record or many, in one query, like Ecto'sRepo.reload. - Add
Query.batches(of:), anAsyncSequenceof primary key batches for reading large results, like Ecto'sRepo.stream. A row that a join repeats appears once. - Load no parent with
loadBelongsTofor a nil foreign key; it threwmissingRequiredField. - Add
DatabaseValue, Spectro's counterpart ofEcto.Type.@Column, filters, changesets, selections, and updates accept any conforming type. Built in:Decimal(numeric),Data(bytea), arrays of conforming types (text[],uuid[],integer[], …),[String: Value]dictionaries (jsonb), andOptionals of all of them. Conform a raw-value enum to store its raw value; aStringraw value fits text, varchar, and PostgreSQL enum columns. Conform aCodabletype toJSONBValueto store it asjsonb; dates injsonbvalues are stored as ISO 8601 strings with milliseconds, as Ecto does. ImplementtoPostgresData(),fromPostgresData(_:), and optionallycast(_:)for other types.PrimaryKeyTypenow refinesDatabaseValue; existing conformances keep compiling. @Columnrequires aDatabaseValuetype. Properties of other types compiled but were silently neither stored nor decoded; conform the type, or store it in a plain property.- Add
FieldType.customandFieldInfo.valueType. Addcustomto exhaustive switches overFieldType. - Decode
Stringcolumns of extension and user-defined types, such ascitextand PostgreSQL enums. They decoded as missing values. - Register
@Columnproperties of typeData. They were neither inserted nor decoded. - Add array filters
contains(_:)(= ANY),containsAll(_:)(@>), andoverlaps(_:)(&&). The latter two cast the column to the bound array type, so[Int]filtersinteger[]columns and[String]filtersvarchar[]and PostgreSQL enum arrays; the cast is free, and keeps GIN indexes usable, when the types match. Decimalholds 38 significant digits: decoding a longernumericfails instead of rounding, encoding NaN throws instead of trapping in PostgresNIO, and changesets reject strings such as"1,000"or"12 USD"instead of reading their numeric prefix.- Add
QueryCondition.fragment(_:)andSQLFragment, like Ecto'sfragment. Interpolated fields become qualified columns, interpolated values become bound parameters, and a literal?, such as thejsonboperator, stays in the SQL. Use fragments inwhereandhaving. - Add
QueryBuilder.table(_:as:)to filter on a joined table's columns. - Add
Query.updateAll(_:)andQuery.deleteAll(), like Ecto'supdate_allanddelete_all. They return the number of affected rows. Assignments areset(_:),set(sql:),increment(by:),push(_:), andpull(_:). Joins, ordering, limits, and offsets select the affected rows.deleteAll()marks rows of@SoftDeleteschemas as deleted, asdeletedoes. - Add
Query.lock(_:_:)forFOR UPDATE,FOR NO KEY UPDATE,FOR SHARE, andFOR KEY SHARE, withNOWAITorSKIP LOCKED. Only the queried table's rows are locked. - Preload relationships of preloaded records, and customize the query that loads a relationship:
.preload(\.$posts) { $0.where { … }.orderBy { … }.preload(\.$comments) }. Many-to-many preloads keep the related query's order. - Add
Repo.preload(_:_:)for records already fetched, in one query for all of them, also inside transactions. - Preload belongs-to relationships through nullable foreign key columns such as
@Column var editorId: UUID?. Accept a preload'sforeignKey:as a property or a column name; a column name loaded no records. Match many-to-many keys of anyPrimaryKeyType. - Add
loggerandonQuerytoDatabaseConfiguration. The logger receives a debug message for each statement with its SQL, rows, and timings, and never its bound values.onQueryreceives aQueryEventwith the SQL, parameters, query time, queue time, row count, and error of each statement, includingBEGIN,COMMIT, andROLLBACK. - Add
Spectro.sandbox(_:), like Ecto's SQL sandbox. It runs its body in a transaction that is always rolled back. Every repository of the instance, in the body and the tasks that inherit its task-local values, uses that transaction.transactionbecomes a savepoint that holds the connection until it ends. Each statement outside a transaction runs in its own savepoint, so a failed one, such as an expected unique violation, leaves the sandbox usable. Statements from aTask { }that outlives the sandbox throw instead of reaching the database. - A transaction's repository and
TransactionContextthrowSpectroError.noActiveTransactiononce the transaction has ended. A repository captured by a task that outlived the transaction ran its statements on the released connection, outside any transaction. - Insert a record with no inserted columns using
DEFAULT VALUES. It trapped.upsertandinsertAllwithout columns throwSpectroError.invalidSchema; they trapped or sent invalid SQL. - Throw from inserts, upserts, and
SchemaMapper.extractData, nowthrows, when a value cannot be encoded. The column was silently left out. - Size
insertAllbatches to stay under PostgreSQL's 65535 parameters for tables with more than 65 columns. - Key
SchemaRegistryby type identity. Two schema types with the same name, such as nested types in different enclosing types, shared metadata. - Throw
SpectroError.resultDecodingFailedfor a non-NULL value a field's type cannot decode, such as an unknown enum label or JSON of another shape. The field silently kept its default, which a later save wrote back. - Decode models selected with
selectFieldsfrom a subset of their columns; the other fields keep their defaults. Decoding trapped. Decoding any other result that lacks a model's column throwsSpectroError.resultDecodingFailedinstead of trapping. - Share one implementation of CRUD between
GenericDatabaseRepoandTransactionRepo. - Report 3.0.0 from
spectro --version, pin the Mint installation examples and the Mintfile to 3.0.0, and require 3.0.0 in the package installation examples.