惯性聚合 高效追踪和阅读你感兴趣的博客、新闻、科技资讯
阅读原文 在惯性聚合中打开

推荐订阅源

S
SegmentFault 最新的问题
博客园 - 三生石上(FineUI控件)
WordPress大学
WordPress大学
博客园 - 【当耐特】
月光博客
月光博客
Vercel News
Vercel News
D
Docker
I
InfoQ
Apple Machine Learning Research
Apple Machine Learning Research
博客园 - 叶小钗
MongoDB | Blog
MongoDB | Blog
GbyAI
GbyAI
有赞技术团队
有赞技术团队
雷峰网
雷峰网
博客园 - 聂微东
小众软件
小众软件
Y
Y Combinator Blog
腾讯CDC
L
LangChain Blog
The GitHub Blog
The GitHub Blog
宝玉的分享
宝玉的分享
Stack Overflow Blog
Stack Overflow Blog
大猫的无限游戏
大猫的无限游戏
T
The Blog of Author Tim Ferriss

Supabase Blog

AI Agents Know About Supabase. They Don't Always Use It Right. Custom OIDC Providers for Supabase Auth 100,000 GitHub stars Supabase docs over SSH Navigating Regional Network Blocks Supabase Joins the Stripe Projects Developer Preview Log Drains: Now available on Pro Supabase Storage: major performance, security, and reliability updates Supabase incident on February 12, 2026 Hydra joins Supabase X / Twitter OAuth 2.0 is now available for Supabase Auth BKND joins Supabase Supabase is now an official Claude connector Supabase PrivateLink is now available Introducing: Postgres Best Practices When to use Read Replicas vs. bigger compute Introducing TRAE SOLO integration with Supabase Supabase Security Retro: 2025 Sync Stripe Data to Your Supabase Database in One Click Building ChatGPT Apps with Supabase Edge Functions and mcp-use Own Your Observability: Supabase Metrics API Introducing iceberg-js: A JavaScript Client for Apache Iceberg Introducing Supabase for Platforms Adding Async Streaming to Postgres Foreign Data Wrappers Build "Sign in with Your App" using Supabase Auth Introducing Seven New Email Templates for Supabase Auth The new Supabase power for Kiro Introducing Supabase ETL Introducing Analytics Buckets Introducing Vector Buckets
Type Constraints in 65 lines of SQL
Oliver Rice · 2023-02-17 · via Supabase Blog

Type Constraints in 65 lines of SQL

PostgreSQL has a rich and extensible type system. Beyond enums and composite types, we can:

  • apply data validation rules
  • override comparison operators like = / + / -
  • create custom aggregations
  • define casting rules between types

With a little effort, a user-defined type can feel indistinguishable from a built-in. In this article we focus on validation and ergonomics while quickly touching on a few other concepts.

To illustrate, we’ll create an semver data type to represent Semantic Versioning values. We’ll then add validation rules to make invalid states unrepresentable.

A (very) loose primer on SemVer:

SemVer is a specification for representing software versions that communicate information about backwards compatibility. The type is typically represented as a string with 5 components.

Where pre-release and metadata are optional.

The intent of each component is outside the scope of this article but, as an example, incrementing the major version number notifies users that the release includes at least one backwards incompatible change.

For a concise representation of the full spec, check out the grammar.

For our purposes, we’ll assume that the SemVer type is a critical component of the application that needs to be queried flexibly and efficiently.

Storing Components#

To that end, we’ll store each component of the version as a separate field on a composite type.


_10

create type semver_components as (

_10

major int,

_10

minor int,

_10

patch int,

_10

pre_release text[],

_10

build_metadata text[]

_10

);


We can create an instance of this type in SQL by casting a tuple as the semver_components type.


_10

select

_10

(1, 2, 3, array['beta', '1'], array['meta'])::semver_components

_10

-- returns: (1,2,3,{'beta','1'},{'meta'})


Unfortunately, our definition is far too permissive.


_10

select

_10

(null, -500, null, array['?'], array[''])::semver_components

_10

-- returns: (,-500,,{'?'},{''


Our data type has no problem accepting invalid components. To list a few of the SemVer rules we violated:

  • Major version must not be null
  • Minor version must be ≥ 0
  • Patch version must not be null
  • Pre-release elements must only include characters [A-z0-9]
  • Build metadata elements may not be empty strings

We need to add some validation rules to meet our “make invalid states unrepresentable” goal.

Validation#

Domains are Postgres’ solution for optionally layering constraints over a data type. Domains are to types what check constraints are to tables. If you’re not familiar with check constraints, you can think of them as equivalent to zod/pydantic in javascript/python.

Let's codify some SemVer rules, layer them on the semver_components type, and give the new domain a friendly name.


_18

create domain semver

_18

as semver_components

_18

check (

_18

-- major: non-null positive integer

_18

(value).major is not null and (value).major >= 0

_18

-- minor: non-null positive integer

_18

and (value).minor is not null and (value).minor >= 0

_18

-- patch: non-null positive integer

_18

and (value).patch is not null and (value).patch >= 0

_18

and semver_elements_match_regex(

_18

(value).pre_release,

_18

'^[A-z0-9]{1,255}$'

_18

)

_18

and semver_elements_match_regex(

_18

(value).build_metadata,

_18

'^[A-z0-9\.]{1,255}$'

_18

)

_18

);


which references a helper function:


_23

create or replace function semver_elements_match_regex(

_23

parts text[],

_23

regex text

_23

)

_23

returns bool

_23

language sql

_23

as $$

_23

-- validates that *parts* nullable array of non-empty strings

_23

-- where each element of *parts* matches *regex*

_23

select

_23

$1 is null

_23

or (

_23

(

_23

select (

_23

bool_and(pr_arr.elem is not null)

_23

and bool_and(pr_arr.elem ~ $2)

_23

)

_23

from

_23

unnest($1) pr_arr(elem)

_23

)

_23

and array_length($1, 1) > 0

_23

)

_23

$$;


Now, if we repeat our positive and negative test cases using the semver type (vs semver_components) we still accept valid states:


_10

-- Success Case

_10

select

_10

(1, 2, 3, array['beta', '1'], array['meta'])::semver,

_10

-- returns: (1,2,3,{'beta','1'},{'meta'})


while invalid states are rejected with an error:


_10

-- Failure Case

_10

select

_10

(null, -500, null, array['?'], array[''])::semver

_10

-- ERROR: value for domain semver violates check constraint "semver_check"

_10

-- SQL state: 23514


Testing#

Our validation doesn’t have to be called manually. The semver domain can be used anywhere you’d use the semver_components type and the validations are automatically applied.


_19

-- A table with a semver column

_19

create table package_version(

_19

id bigserial primary key,

_19

package_name text not null,

_19

package_semver semver not null -- semver column

_19

);

_19

_19

-- Insert some valid records

_19

insert into package_version( package_name, package_semver )

_19

values

_19

('supabase-js', (2, 2, 3, null, null)),

_19

('supabase-js', (2, 0, 0, array['rc', '1'], null)

_19

);

_19

_19

-- Attempt to insert an invalid record (major is null)

_19

insert into package_version( package_name, package_semver )

_19

values

_19

('invalid-js', (null, 1, 0, array['asdf'], null));

_19

-- ERROR: value for domain semver violates check constraint "semver_check"


Good stuff!

We’re 48 lines of SQL in and have solved for making invalid states unrepresentable. Now lets think about ergonomics.

Displaying#

Now that our data type is well constrained, you might notice that selecting values from a semver typed column returns a tuple, rather than the SemVer string we’re used to seeing.


_10

select

_10

*

_10

from

_10

package_version

_10

/*

_10

id | package_name | package_semver

_10

-------------------------------------

_10

1 | supabase-js | (2,2,3,,)

_10

2 | supabase-js | (2,0,0,"{rc,1}",)

_10

*/


For example: (2,0,0,"{rc,1}",) vs 2.0.0-rc.1

We could work around that problem with some custom casts, but I’d recommend keeping everything explicit with a function call.


_16

create or replace function semver_to_text(semver)

_16

returns text

_16

immutable

_16

language sql

_16

as $$

_16

select

_16

format('%s.%s.%s', $1.major, $1.minor, $1.patch)

_16

|| case

_16

when $1.pre_release is null then ''

_16

else format('-%s', array_to_string($1.pre_release, '.'))

_16

end

_16

|| case

_16

when $1.build_metadata is null then ''

_16

else format('+%s', array_to_string($1.build_metadata, '.'))

_16

end

_16

$$;


Which allows us to query the package_version table and retrieve a string representation of the data.


_12

select

_12

id,

_12

package_name,

_12

semver_to_text(package_semver) as ver -- cast as text

_12

from

_12

package_version

_12

/*

_12

id | package_name | ver

_12

------------------------------

_12

1 | supabase-js | 2.2.3

_12

2 | supabase-js | 2.0.0-rc.1

_12

*/


Or, better yet, use a generated column


_10

create table package_version(

_10

id bigserial primary key,

_10

package_name text not null,

_10

package_semver semver not null,

_10

semver_text text generated always as (semver_to_text(package_semver)) stored

_10

);


so the text representation is persisted along with the semver type and incurs no query/filter penalty.

Other Tricks#

Postgres provides all the tools you could want to make your data types/domains work with SQL as seamlessly as builtins.

For example, you could:

to name a few.

Aligning the right parts of your business’ logic with the database can dramatically improve throughput, decrease IO, and simplify application code.

Conclusion#

Admittedly, building performant and ergonomic custom data types in Postgres involves a lot of ceremony.

That said, in cases where:

  • the type’s data integrity is critical
  • the type is well specified
  • the type’s spec does not change (or changes infrequently)

Teaching Postgres to have first class support for your custom type can be transformative for data integrity and performance.