feat(pcd): enhance type generator with semantic types - check constraints, booleans

This commit is contained in:
Sam Chau
2026-01-27 22:33:14 +10:30
parent dc837a5254
commit bec2d0c320
2 changed files with 127 additions and 27 deletions

View File

@@ -119,32 +119,106 @@ interface ForeignKeyInfo {
on_delete: string;
}
interface CheckConstraint {
column: string;
values: string[];
}
interface TableInfo {
name: string;
columns: ColumnInfo[];
foreignKeys: ForeignKeyInfo[];
checkConstraints: CheckConstraint[];
createSql: string;
}
/**
* Parse CHECK constraints from CREATE TABLE SQL to extract enum values
* Looks for patterns like: CHECK (column IN ('val1', 'val2', 'val3'))
*/
function parseCheckConstraints(createSql: string): CheckConstraint[] {
const constraints: CheckConstraint[] = [];
// Match CHECK (column_name IN ('val1', 'val2', ...)) patterns
// Handles both quoted strings and unquoted identifiers
const checkPattern = /CHECK\s*\(\s*(\w+)\s+IN\s*\(\s*([^)]+)\s*\)\s*\)/gi;
let match;
while ((match = checkPattern.exec(createSql)) !== null) {
const column = match[1];
const valuesStr = match[2];
// Extract individual values (handles 'quoted' and unquoted)
const values: string[] = [];
const valuePattern = /'([^']+)'/g;
let valueMatch;
while ((valueMatch = valuePattern.exec(valuesStr)) !== null) {
values.push(valueMatch[1]);
}
if (values.length > 0) {
constraints.push({ column, values });
}
}
return constraints;
}
/**
* Check if a column name matches boolean naming patterns
*/
function isBooleanColumn(columnName: string, columnType: string): boolean {
// Must be INTEGER type
if (!columnType.toUpperCase().includes('INT')) {
return false;
}
const name = columnName.toLowerCase();
// Prefix patterns
const booleanPrefixes = ['is_', 'has_', 'bypass_', 'enable_', 'include_', 'except_', 'replace_'];
if (booleanPrefixes.some((prefix) => name.startsWith(prefix))) {
return true;
}
// Suffix patterns
const booleanSuffixes = ['_allowed', '_enabled'];
if (booleanSuffixes.some((suffix) => name.endsWith(suffix))) {
return true;
}
// Exact matches
const booleanExactNames = ['negate', 'required', 'enabled', 'rename', 'should_match', 'upgrades_allowed'];
if (booleanExactNames.includes(name)) {
return true;
}
return false;
}
function introspectDatabase(db: Database): TableInfo[] {
// Get all table names
// Get all table names and their CREATE statements
const tables = db
.prepare(
`SELECT name FROM sqlite_master
`SELECT name, sql FROM sqlite_master
WHERE type='table' AND name NOT LIKE 'sqlite_%'
ORDER BY name`
)
.all() as { name: string }[];
.all() as { name: string; sql: string }[];
const tableInfos: TableInfo[] = [];
for (const { name } of tables) {
for (const { name, sql } of tables) {
// Get column info
const columns = db.prepare(`PRAGMA table_info('${name}')`).all() as ColumnInfo[];
// Get foreign key info
const foreignKeys = db.prepare(`PRAGMA foreign_key_list('${name}')`).all() as ForeignKeyInfo[];
tableInfos.push({ name, columns, foreignKeys });
// Parse CHECK constraints from CREATE TABLE SQL
const checkConstraints = parseCheckConstraints(sql || '');
tableInfos.push({ name, columns, foreignKeys, checkConstraints, createSql: sql || '' });
}
return tableInfos;
@@ -181,6 +255,31 @@ function sqliteTypeToTs(sqliteType: string, nullable: boolean): string {
return nullable ? `${tsType} | null` : tsType;
}
/**
* Get the semantic TypeScript type for a column
* Uses CHECK constraints for union types and naming patterns for booleans
*/
function getSemanticType(
column: ColumnInfo,
checkConstraints: CheckConstraint[],
nullable: boolean
): string {
// Check if this column has a CHECK IN constraint (union type)
const constraint = checkConstraints.find((c) => c.column === column.name);
if (constraint && constraint.values.length > 0) {
const unionType = constraint.values.map((v) => `'${v}'`).join(' | ');
return nullable ? `(${unionType}) | null` : unionType;
}
// Check if this is a boolean column based on naming patterns
if (isBooleanColumn(column.name, column.type)) {
return nullable ? 'boolean | null' : 'boolean';
}
// Fall back to standard SQLite type mapping
return sqliteTypeToTs(column.type, nullable);
}
/**
* Convert snake_case to PascalCase
*/
@@ -269,6 +368,7 @@ function generateDatabaseInterface(tables: TableInfo[]): string {
/**
* Generate row types (non-Generated versions for query results)
* Uses semantic types: booleans as boolean, CHECK IN as union types
*/
function generateRowType(table: TableInfo): string {
const rowTypeName = `${toPascalCase(table.name)}Row`;
@@ -278,7 +378,7 @@ function generateRowType(table: TableInfo): string {
for (const column of table.columns) {
const nullable = isNullable(column);
const tsType = sqliteTypeToTs(column.type, nullable);
const tsType = getSemanticType(column, table.checkConstraints, nullable);
lines.push(`\t${column.name}: ${tsType};`);
}

View File

@@ -3,10 +3,10 @@
*
* AUTO-GENERATED - DO NOT EDIT MANUALLY
*
* Generated from: https://github.com/Dictionarry-Hub/schema/blob/1.0.0/ops/0.schema.sql
* Generated at: 2026-01-27T11:08:44.693Z
* Generated from: https://github.com/Dictionarry-Hub/schema/blob/local/ops/0.schema.sql
* Generated at: 2026-01-27T11:52:25.907Z
*
* To regenerate: deno task generate:pcd-types --version=1.0.0
* To regenerate: deno task generate:pcd-types --version=local
*/
import type { Generated } from 'kysely';
@@ -370,7 +370,7 @@ export interface QualityProfilesRow {
id: number;
name: string;
description: string | null;
upgrades_allowed: number;
upgrades_allowed: boolean;
minimum_custom_format_score: number;
upgrade_until_score: number;
upgrade_score_increment: number;
@@ -403,7 +403,7 @@ export interface QualityProfileQualitiesRow {
quality_name: string | null;
quality_group_name: string | null;
position: number;
enabled: number;
enabled: boolean;
upgrade_until: number;
}
@@ -422,7 +422,7 @@ export interface QualityProfileCustomFormatsRow {
export interface TestEntitiesRow {
id: number;
type: string;
type: 'movie' | 'series';
tmdb_id: number;
title: string;
year: number | null;
@@ -433,7 +433,7 @@ export interface TestEntitiesRow {
export interface TestReleasesRow {
id: number;
entity_type: string;
entity_type: 'movie' | 'series';
entity_tmdb_id: number;
title: string;
size_bytes: number | null;
@@ -450,7 +450,7 @@ export interface CustomFormatsRow {
id: number;
name: string;
description: string | null;
include_in_rename: number;
include_in_rename: boolean;
created_at: string;
updated_at: string;
}
@@ -466,8 +466,8 @@ export interface CustomFormatConditionsRow {
name: string;
type: string;
arr_type: string;
negate: number;
required: number;
negate: boolean;
required: boolean;
created_at: string;
updated_at: string;
}
@@ -477,7 +477,7 @@ export interface CustomFormatTestsRow {
custom_format_name: string;
title: string;
type: string;
should_match: number;
should_match: boolean;
description: string | null;
created_at: string;
}
@@ -492,7 +492,7 @@ export interface ConditionLanguagesRow {
custom_format_name: string;
condition_name: string;
language_name: string;
except_language: number;
except_language: boolean;
}
export interface ConditionPatternsRow {
@@ -561,11 +561,11 @@ export interface RegularExpressionTagsRow {
export interface DelayProfilesRow {
id: number;
name: string;
preferred_protocol: string;
preferred_protocol: 'prefer_usenet' | 'prefer_torrent' | 'only_usenet' | 'only_torrent';
usenet_delay: number | null;
torrent_delay: number | null;
bypass_if_highest_quality: number;
bypass_if_above_custom_format_score: number;
bypass_if_highest_quality: boolean;
bypass_if_above_custom_format_score: boolean;
minimum_custom_format_score: number | null;
created_at: string;
updated_at: string;
@@ -575,10 +575,10 @@ export interface DelayProfilesRow {
export interface RadarrNamingRow {
name: string | null;
rename: number;
rename: boolean;
movie_format: string;
movie_folder_format: string;
replace_illegal_characters: number;
replace_illegal_characters: boolean;
colon_replacement_format: string;
created_at: string;
updated_at: string;
@@ -586,13 +586,13 @@ export interface RadarrNamingRow {
export interface SonarrNamingRow {
name: string | null;
rename: number;
rename: boolean;
standard_episode_format: string;
daily_episode_format: string;
anime_episode_format: string;
series_folder_format: string;
season_folder_format: string;
replace_illegal_characters: number;
replace_illegal_characters: boolean;
colon_replacement_format: number;
custom_colon_replacement_format: string | null;
multi_episode_style: number;
@@ -603,7 +603,7 @@ export interface SonarrNamingRow {
export interface RadarrMediaSettingsRow {
name: string | null;
propers_repacks: string;
enable_media_info: number;
enable_media_info: boolean;
created_at: string;
updated_at: string;
}
@@ -611,7 +611,7 @@ export interface RadarrMediaSettingsRow {
export interface SonarrMediaSettingsRow {
name: string | null;
propers_repacks: string;
enable_media_info: number;
enable_media_info: boolean;
created_at: string;
updated_at: string;
}