clihelper.sqlite.psm1

#!/usr/bin/env pwsh
using namespace System.Collections
using namespace Microsoft.data.Sqlite
using namespace System.Collections.Generic


#region Classes
enum DBMigrationMode {
  INCREMENTAL # Assume the database already exists and only apply changes "IF NOT EXISTS"
  CREATE      # Only create a new database if it doesn't exist, dropping any existing tables
  OVERWRITE   # remove the db file and create a new one
}

enum SqliteConstraintType {
  Index
  ForeignKey
  PrimaryKey
  Check
}

enum SqliteOrdering {
  ASC
  DESC
  NONE
}

enum SqliteTableOption {
  WithoutRowId
  Strict
}

enum SqliteType {
  TEXT
  INTEGER
  REAL
  BLOB
  NULL
}


class SQLiteConstraint {
  [SqliteConstraintType]$ConstraintType

  SQLiteConstraint() {
    # Default constructor
  }

  SQLiteConstraint([string]$constraintType) {
    $this.ConstraintType = $constraintType
  }

  SQLiteConstraint([SqliteConstraintType]$constraintType) {
    $this.ConstraintType = $constraintType
  }
}



class SqliteIndexConstraint : SqliteConstraint {
  [string]$Name # Name of the index
  [string]$Table # Name of the table on which the index is created
  [bool] $Unique = $false # Indicates if the index is unique
  [bool] $ifNotExists = $true # Indicates if the index is created with IF NOT EXISTS
  [string]$SchemaName # Schema name for the index (optional, default is null)
  [string[]]$Columns
  [string]$Where # Optional WHERE clause for partial indexes

  SqliteIndexConstraint() {
    # Default constructor
  }

  SqliteIndexConstraint([IDictionary]$Definition) {
    $this.Name = $Definition['Name']

    if (![string]::IsNullOrEmpty($Definition.Unique)) {

      [bool]$refValue = $this.Unique
      [void][bool]::TryParse($Definition['Unique'], [ref]$refValue)

      $this.Unique = $refValue
    }

    if (![string]::IsNullOrEmpty($Definition.ifNotExists)) {
      [void][bool]::TryParse($Definition['ifNotExists'], [ref]$this.ifNotExists)
    }

    $this.SchemaName = $Definition['SchemaName']
    $this.Table = $Definition['Table']
    $this.Columns = ($Definition['Columns'] -as [string[]]).Where({ $_ -ne $null }) # Ensure Columns is an array of strings
    if ($Definition.keys -contains 'Where') {

      $this.Where = $Definition['Where']
    }

    $this.ValidateDefinition()
  }

  [void] ValidateDefinition() {
    if (!$this.Name) {
      throw [System.ArgumentException]::new('Name is required for an index.')
    }

    if (!$this.Table) {
      throw [System.ArgumentException]::new('The Table''s name is required.')
    }

    if (!$this.Columns -or $this.Columns.Count -eq 0) {
      throw [System.ArgumentException]::new('At least one column is required for the index.')
    }
  }

  [string] CreateString() {
    [System.Text.StringBuilder]$sb = [System.Text.StringBuilder]::new()
    $sb.Append('CREATE')
    if ($this.Unique) {
      $sb.Append(' UNIQUE')
    }

    $sb.Append(' INDEX ')

    if ($this.ifNotExists) {
      $sb.Append('IF NOT EXISTS ')
    }

    if ($this.SchemaName) {
      $sb.Append('{0}.' -f $this.SchemaName)
    }

    $sb.Append(('{0} ON {1}(' -f $this.Name, $this.Table))

    if ($this.Columns -and $this.Columns.Count -gt 0) {
      $sb.Append(($this.Columns -join ', '))
    }

    $sb.Append(')')
    if ($this.WHERE) {
      $sb.Append((' WHERE {0}' -f $this.Where))
    }

    $sb.AppendLine(';')

    return $sb.ToString()
  }
}


class SqliteForeignKeyTableConstraint : SqliteConstraint {
  [string]$Name
  [string]$Table
  [string[]]$Columns
  [string]$ForeignTable
  [string[]]$ForeignColumns
  [string]$OnUpdate
  [string]$OnDelete
  [string]$Match = 'NONE' # Default match type is NONE

  SqliteForeignKeyTableConstraint() : base('ForeignKey') {
    # Default constructor
  }

  SqliteForeignKeyTableConstraint([System.Collections.IDictionary]$Definition) : base('ForeignKey') {
    $this.Name = $Definition['Name']
    $this.Table = $Definition['Table']
    $this.Columns = $Definition['Columns'] -as [string[]]
    $this.ForeignTable = $Definition['ForeignTable']
    $this.ForeignColumns = $Definition['ForeignColumns'] -as [string[]]

    if ($Definition.Keys -contains 'OnUpdate') {
      $this.OnUpdate = $Definition['OnUpdate']
    }

    if ($Definition.Keys -contains 'OnDelete') {
      $this.OnDelete = $Definition['OnDelete']
    }

    if ($Definition.Keys -contains 'Match') {
      $this.Match = $Definition['Match']
    }
    $this.ValidateDefinition()
  }

  [void] ValidateDefinition() {
    if (!$this.Name) {
      throw [System.ArgumentException]::new('Name is required for foreign key constraint.')
    }

    if (!$this.Table) {
      throw [System.ArgumentException]::new('Table is required for foreign key constraint.')
    }

    if (!$this.ForeignTable) {
      throw [System.ArgumentException]::new('ForeignTable is required for foreign key constraint.')
    }

    if (!$this.Columns -or $this.Columns.Count -eq 0) {
      throw [System.ArgumentException]::new('At least one column is required for foreign key constraint.')
    }

    if (!$this.ForeignColumns -or $this.ForeignColumns.Count -eq 0) {
      throw [System.ArgumentException]::new('At least one foreign column is required for foreign key constraint.')
    }
  }

  [string] ToString() {
    $this.ValidateDefinition()
    # Generate the SQL representation of the foreign key constraint
    # https://sqlite.org/syntax/table-constraint.html
    # https://sqlite.org/syntax/foreign-key-clause.html
    [System.Text.StringBuilder]$sb = [System.Text.StringBuilder]::new()
    [void]$sb.AppendLine(('CONSTRAINT {0} FOREIGN KEY (' -f $this.Name))
    [void]$sb.Append((' {0}' -f ($this.Columns -join ', ')))
    [void]$sb.AppendLine(') REFERENCES')
    [void]$sb.Append((' {0} (' -f $this.ForeignTable))

    [void]$sb.Append((' {0})' -f ($this.ForeignColumns -join ', ')))
    if (![string]::IsNullOrEmpty($this.OnUpdate) -or ![string]::IsNullOrEmpty($this.OnDelete)) {
      if (![string]::IsNullOrEmpty($this.OnUpdate)) {
        [void]$sb.AppendLine((' ON UPDATE {0}' -f $this.OnUpdate.ToUpper()))
      }

      if (![string]::IsNullOrEmpty($this.OnDelete)) {
        [void]$sb.AppendLine((' ON DELETE {0}' -f $this.OnDelete.ToUpper()))
      }
    }

    # Add MATCH clause if needed
    if ($this.Match -and $this.Match -ne 'NONE') {
      [void]$sb.AppendLine((' MATCH {0}' -f $this.Match.ToUpper()))
    }
    [void]$sb.AppendLine(');')
    return $sb.ToString()
  }
}



class SqlitePrimaryKeyTableConstraint : SqliteConstraint {
  [string]$Name
  [string[]]$Columns
  [string]$ConflictClause = 'NONE' # Default conflict clause

  SqlitePrimaryKeyTableConstraint() : base('PrimaryKey') {
    # Default constructor
  }

  SqlitePrimaryKeyTableConstraint([IDictionary]$Definition) {
    $this.Name = $Definition['Name']
    $this.Columns = ($Definition['Columns'] -as [string[]]).Where({ $_ -ne $null }) # Ensure Columns is an array of strings
    if ($Definition.keys -contains 'ConflictClause') {

      $this.ConflictClause = $Definition['ConflictClause']
    }
  }

  [string]ToString() {
    return (
      'CONSTRAINT {0} PRIMARY KEY ({1}){2}' -f $this.Name, ($this.Columns -join ', '), $this.GetConflictClauseString()
    )
  }

  [string]GetConflictClauseString() {
    if ($this.ConflictClause -and $this.ConflictClause -ne "NONE") {
      return " ON CONFLICT {0}" -f $this.ConflictClause
    } else {
      return ''
    }
  }
}


class SqliteCheckTableConstraint : SqliteConstraint {
  [string]$TableName
  [string]$ColumnName
  [string]$CheckExpression # Expression for CHECK constraints


  SqliteCheckTableConstraint() : base('INDEX') {
    # Default constructor
  }

  SqliteCheckTableConstraint([System.Collections.IDictionary]$Definition) : base('INDEX') {
    $this.TableName = $Definition['TableName']
    $this.ColumnName = $Definition['ColumnName']
    $this.CheckExpression = $Definition['CheckExpression']

    $this.ValidateConstraint()
  }

  [void] ValidateConstraint() {
    if (!$this.TableName) {
      throw "TableName is required for CHECK constraints."
    }

    if (!$this.CheckExpression) {
      throw "CheckExpression is required for CHECK constraints."
    }
  }

  [string] ToString() {
    $this.ValidateConstraint() # Ensure the constraint is valid before converting to string
    return "CONSTRAINT {0} CHECK ({1})" -f $this.Name, $this.CheckExpression
  }
}


class SQLiteColumn {
  # In SQLite, PRIMARY KEY that are INTEGER are automatically indexed and auto-incremented (alias for ROWID)
  [string]$Name
  [SqliteType]$Type # SQLite data type

  #region PK Constraint
  [bool]$PrimaryKey = $false # Primary key column
  [System.Nullable[SqliteOrdering]]$PrimaryKeyOrder = $null # Order of the primary key (ASC or DESC)
  [bool]$AutoIncrement # Auto-incremented column (only for INTEGER PRIMARY KEY)
  #endregion

  [bool]$AllowNull = $true # Allow NULL values (if false, NOT NULL constraint is applied)

  [bool]$Unique = $false # Unique constraint
  [string]$UniqueConflictClause # Conflict clause for unique constraint (e.g., REPLACE, IGNORE)
  [string]$CheckExpression # Check constraint expression (for validation on write operations)
  [object]$DefaultValue # Default value for the column (can be a string, number, or expression). Default is null
  [string]$Collation # Collation for the column (e.g., BINARY, NOCASE, RTRIM)
  [bool]$Indexed = $false # Indexed column
  [string]$References # Foreign key reference (otherwise use a TableConstraint)

  SQLiteColumn() {
    # Default constructor
  }

  SQLiteColumn([IDictionary] $Definition) {
    $this.Name = $Definition['Name']
    $this.Type = $Definition['Type']
    # [void][bool]::TryParse($Definition['PrimaryKey'], [ref]$this.PrimaryKey)
    # $this.PrimaryKeyOrder = $Definition['PrimaryKeyOrder']

    if ($Definition.Keys -contains 'PrimaryKey' -and ![string]::IsNullOrEmpty($Definition['PrimaryKey'])) {

      #TryParse to handle cases where PrimaryKey is not a boolean

      [bool]$refValue = $this.PrimaryKey
      [void][bool]::TryParse($Definition['PrimaryKey'], [ref]$refValue)

      $this.PrimaryKey = $refValue
      if ($this.PrimaryKeyOrder -and $this.PrimaryKeyOrder -ne [SqliteOrdering]::None) {

        #Ensure PrimaryKeyOrder is set to None if PrimaryKey is false

        $this.PrimaryKeyOrder = 'NONE'
      }
    }

    if ($Definition.Keys -contains 'AutoIncrement' -and ![string]::IsNullOrEmpty($this.AutoIncrement) -and $this.Type -eq [SqliteType]::Integer) {
      Write-Host ('[WARNING] AutoIncrement is only applicable to INTEGER PRIMARY KEY columns. Setting AutoIncrement to false for column {0}.' -f $this.Name)

      [bool]$refValue = $this.AutoIncrement
      [void][bool]::TryParse($Definition['AutoIncrement'], [ref]$refValue)
      $this.AutoIncrement = $refValue
    }

    if ($Definition.keys -contains 'AllowNull' -and ![string]::IsNullOrEmpty($Definition['AllowNull'])) {

      #TryParse to handle cases where AllowNull is not a boolean
      [bool]$refValue = $this.AllowNull
      [void][bool]::TryParse($Definition['AllowNull'], [ref]$refValue)
      $this.AllowNull = $refValue
    }

    if ($Definition.Keys -contains 'Unique' -and ![string]::IsNullOrEmpty($Definition['Unique'])) {
      #TryParse to handle cases where Unique is not a boolean
      [bool]$refValue = $this.Unique
      $result = [bool]::TryParse($Definition['Unique'], [ref]$refValue)
      $this.Unique = $refValue
      #Log the conversion result
      Write-Debug -Message (
        'Unique constraint for column {0} set to {1} should be {2} (conversion success: {3})' -f $this.Name, $this.Unique, $Definition['Unique'], $result
      )
    }

    if ($Definition.Keys -contains 'UniqueConflictClause' -and ![string]::IsNullOrEmpty($Definition['UniqueConflictClause'])) {

      $this.UniqueConflictClause = $Definition['UniqueConflictClause']
    }

    if ($Definition.Keys -contains 'DefaultValue' -and ![string]::IsNullOrEmpty($Definition['DefaultValue'])) {

      $this.DefaultValue = $Definition['DefaultValue']
    }

    if ($Definition.Keys -contains 'Collation' -and ![string]::IsNullOrEmpty($Definition['Collation'])) {

      $this.Collation = $Definition['Collation']
    }

    if ($Definition.Keys -contains 'References' -and ![string]::IsNullOrEmpty($Definition['References'])) {

      $this.References = $Definition['References']
    }

    if ($Definition.Keys -contains 'CheckExpression' -and ![string]::IsNullOrEmpty($Definition['CheckExpression'])) {

      $this.CheckExpression = $Definition['CheckExpression']
    }
  }

  [void] ValidateDefinition() {
    if (!$this.Name) {
      throw [System.ArgumentException]::new('Column Name is required.')
    }

    if ($null -eq $this.Type) {
      throw [System.ArgumentException]::new('Column Type is required.')
    }

    if ($this.PrimaryKey -and $this.AllowNull) {
      Write-Host ('[WARNING] Although SQLite allows this, we recommend that Primary key columns do not allow NULL values.')
    }
  }

  [string] ToString() {
    $this.ValidateDefinition()
    # Generate the column definition string
    # https://sqlite.org/syntax/column-def.html
    [System.Text.StringBuilder]$sb = [System.Text.StringBuilder]::new()
    [void]$sb.Append(('{0} {1}' -f $this.Name, $this.Type.ToString().ToUpper()))

    #region Column Constraints
    # https://sqlite.org/syntax/column-constraint.html
    if ($this.PrimaryKey) {
      [void]$sb.Append(' PRIMARY KEY')
      if ($this.PrimaryKeyOrder -and $this.PrimaryKeyOrder -ne [SqliteOrdering]::None) {

        #Append the order of the primary key if specified
        [void]$sb.Append((' {0}' -f $this.PrimaryKeyOrder.ToString().ToUpper()))
      }


      if ($this.AutoIncrement -or $this.Type -eq [SqliteType]::Integer) {

        #If the column is an INTEGER PRIMARY KEY, it is auto-incremented by default (alias for ROWID)
        [void]$sb.Append(' AUTOINCREMENT')
      }
    } elseif (!$this.AllowNull) {
      [void]$sb.Append(' NOT NULL')
    } elseif ($this.Unique) {
      [void]$sb.Append(' UNIQUE')
      if ($this.UniqueConflictClause) {
        [void]$sb.Append((' ON CONFLICT {0}' -f $this.UniqueConflictClause))
      }
    } elseif (![string]::IsNullOrEmpty($this.CheckExpression)) {
      [void]$sb.Append((' CHECK ({0})' -f $this.CheckExpression))
    } elseif (![string]::IsNullOrEmpty($this.DefaultValue)) {
      if ($this.DefaultValue -is [string]) {
        $useDefaultValue = $this.DefaultValue.Replace("'", "''") # Escape single quotes in string literals
      } else {
        $useDefaultValue = $this.DefaultValue
      }


      [void]$sb.Append((' DEFAULT {0}' -f $useDefaultValue))
    } elseif ($this.Collation) {
      [void]$sb.Append((' COLLATE {0}' -f $this.Collation))
    } elseif ($this.References) {
      $definition += " REFERENCES $($this.References)"
    }

    #endregion
    return $sb.ToString()
  }
}


class SqliteTable {
  [string]$Name
  [string]$Schema
  [bool] $ifNotExists = $true # If true, the table will only be created if it does not already exist
  [SqliteColumn[]]$Columns
  [SQLiteConstraint[]]$Constraints = @() # List of constraints for the table
  [SQLiteTableOption[]]$Options = @() # Options for the table, such as WithoutRowId or Strict

  SqliteTable() {
    # Default constructor
  }

  SqliteTable([System.Collections.IDictionary]$Definition) {
    $this.Name = $Definition['Name']
    if ($Definition.Keys -contains 'Schema') {
      $this.Schema = $Definition['Schema']
    }

    if ($Definition.keys -contains 'Columns') {
      foreach ($columnName in $Definition['Columns'].keys) {
        $currentColumn = $Definition['Columns'][$columnName]
        $currentColumn['Name'] = $columnName
        $this.Columns += [SqliteColumn]::new($currentColumn)
      }
    }

    if ($Definition.keys -contains 'Strict') {
      $this.Strict = $Definition['Strict']
    }
    if ($Definition.keys -contains 'Constraints') {
      foreach ($constraint in $Definition['Constraints']) {
        $constraint['Table'] = $this.Name # Ensure the constraint has the table name set
        switch ($constraint['Type']) {
          'ForeignKey' {
            $this.Constraints += [SqliteForeignKeyTableConstraint]::new($constraint)
          }
          'Check' {
            $this.Constraints += [SqliteCheckTableConstraint]::new($constraint)
          }
          'PrimaryKey' {
            $this.Constraints += [SqlitePrimaryKeyTableConstraint]::new($constraint)
          }
          'Index' {
            $this.Constraints += [SqliteIndexConstraint]::new($constraint)
          }
          default {
            Write-Host ('[WARNING] Unknown constraint type {0} for table {1}. Skipping.' -f $constraint['Type'], $this.Name)
          }
        }
      }
    }

    if ($Definition.keys -contains 'Options') {
      foreach ($option in $Definition['Options']) {
        $this.Options = ($option -as [SQLiteTableOption[]])
      }
    }
  }
  [void] ValidateDefinition() {
    if (!$this.Name) {
      throw [System.ArgumentException]::new('Table Name is required.')
    }
    if ($this.Columns.Count -eq 0) {
      throw [System.ArgumentException]::new('At least one column is required in the table definition.')
    }
    foreach ($column in $this.Columns) {
      $column.ValidateDefinition()
    }
  }

  [string] CreateString() {
    $this.ValidateDefinition()
    # Generate the CREATE TABLE statement
    # https://sqlite.org/syntax/create-table-stmt.html
    [System.Text.StringBuilder]$sb = [System.Text.StringBuilder]::new()
    [void]$sb.Append('CREATE')
    if ($this.Temporary) {
      [void]$sb.Append(' TEMPORARY')
    }

    [void]$sb.Append(' TABLE')

    if ($this.ifNotExists) {
      [void]$sb.Append(' IF NOT EXISTS ')
    }

    if ($this.Schema) {
      [void]$sb.Append(('{0}.' -f $this.Schema))
    }

    # Append the table name
    [void]$sb.Append((' {0}' -f $this.Name))
    # AS Select Statement goes here (not supported in this class yet)

    # Append the columns
    [void]$sb.AppendLine(' (')

    [int]$i = 0
    for ($i; $i -lt $this.Columns.Count; $i++) {
      # Write-Debug -Message ('Adding column {0} to table {1}' -f $this.Columns[$i].Name, $this.Name)
      [void]$sb.Append((' {0}' -f $this.Columns[$i].ToString()))
      if ($i -lt ($this.Columns.Count - 1)) {

        #There's more columns to append, and it's not the first one
        [void]$sb.AppendLine(',')
      } else {
        [void]$sb.AppendLine('')
      }
    }

    [void]$sb.Append(')')

    if ($this.Options.Count -gt 0) {
      [void]$sb.Append(' ')
      [void]$sb.Append(($this.Options | ForEach-Object { $_.ToString() }) -join ', ')
    }

    $sb.AppendLine(';')
    return $sb.ToString()
  }
}


class SqliteDBSchema {
  [SqliteTable[]] $Tables = @()
  # [SqliteView[]] $Views
  [SqliteIndexConstraint[]] $Indexes = @()

  SqliteDBSchema() {
    # Default constructor
  }

  SqliteDBSchema([IDictionary] $Definition) {
    if ($Definition.Keys -contains 'Tables') {
      foreach ($tableName in $Definition['Tables'].Keys) {
        $currentTable = $Definition['Tables'][$tableName]
        $currentTable['Name'] = $tableName

        $this.Tables += [SqliteTable]::new($currentTable)
      }
    }

    if ($Definition.Keys -contains 'Indexes') {
      foreach ($indexName in $Definition['Indexes'].Keys) {
        $currentIndex = $Definition['Indexes'][$indexName]
        $currentIndex['Name'] = $indexName

        $this.Indexes += [SqliteIndexConstraint]::new($currentIndex)
      }
    }
  }

  [void] ValidateDefinition() {
    if (!$this.Tables -or $this.Tables.Count -eq 0) {
      throw [System.ArgumentException]::new('At least one table is required in the schema.')
    }

    foreach ($table in $this.Tables) {
      $table.ValidateDefinition()
    }

    if ($this.Indexes) {
      foreach ($index in $this.Indexes) {
        $index.ValidateDefinition()
      }
    }
  }

  [string] GetSchemaSDL() {
    $this.ValidateDefinition()
    [System.Text.StringBuilder]$sb = [System.Text.StringBuilder]::new()

    foreach ($table in $this.Tables) {
      $sb.AppendLine($table.CreateString())
    }

    foreach ($index in $this.Indexes) {
      $sb.AppendLine($index.CreateString())
    }

    return $sb.ToString()
  }
}


class SQLiteDBConfig {
  hidden [string] $ConfigurationFile
  [string] $DatabasePath
  [string] $DatabaseFile
  [string] $ConnectionString
  [string] $Version = '0'
  [SqliteDBSchema] $Schema


  SQLiteDBConfig() {
    # Default constructor
  }

  SQLiteDBConfig([string]$DatabasePath, [string]$DatabaseFile) {
    $this.DatabasePath = [SqliteHelper]::GetAbsolutePath([SqliteHelper]::ExpandString($DatabasePath))
    $this.DatabaseFile = [SqliteHelper]::ExpandString($DatabaseFile)
    $this.ConnectionString = 'Data Source={0};' -f ([IO.Path]::Combine($DatabasePath, $DatabaseFile))
  }

  SQLiteDBConfig([string]$StringInfo) {
    if (![IO.File]::Exists($StringInfo)) {
      #Test that the string is a valid connection string
      if ($StringInfo -notmatch '^Data Source=.*$') {
        throw "Invalid SQLite connection string format: $StringInfo"
      } else {

        $this.ConnectionString = $StringInfo
        return
      }
    } else {
      $configFileObject = Get-Content -Path $StringInfo | ConvertFrom-Yaml -Ordered

      $this.SetObjectProperties($configFileObject)
    }
  }

  SQLiteDBConfig ([System.Collections.IDictionary]$Definition) {
    $this.SetObjectProperties($Definition)
  }

  static [SQLiteDBConfig] Load([string]$ConfigFile) {
    $ConfigFile = [SqliteHelper]::GetAbsolutePath($ConfigFile)
    return [SQLiteDBConfig]::new($ConfigFile)
  }

  hidden SetObjectProperties([System.Collections.IDictionary]$Definition) {
    if ($Definition.Keys -contains 'DatabasePath') {
      $dbPath = [SqliteHelper]::ExpandString($Definition['DatabasePath'])
      $this.DatabasePath = [SqliteHelper]::GetAbsolutePath($dbPath)
    }

    if ($Definition.Keys -contains 'DatabaseFile') {
      $dbFile = [SqliteHelper]::ExpandString($Definition['DatabaseFile'])
      $this.DatabaseFile = $dbFile
    }

    if ($Definition.Keys -contains 'ConnectionString') {
      $this.ConnectionString = $Definition['ConnectionString']
    } else {
      if ($this.DatabaseFile) {
        $this.ConnectionString = 'Data Source={0};' -f (Join-Path -Path $this.DatabasePath -ChildPath $this.DatabaseFile)
      } else {
        throw [System.ArgumentException]::new('DatabasePath and DatabaseFile must be set to construct a valid connection string.')
      }
    }

    if ($Definition.Keys -contains 'Version') {
      $this.Version = $Definition['Version']
    }

    if ($Definition.Keys -contains 'Schema') {
      $this.Schema = [SqliteDBSchema]::new($Definition['Schema'])
    }
  }

  [string] GetDatabaseSDL() {
    if (!$this.Schema) {
      throw [System.InvalidOperationException]::new('Schema is not defined in the database configuration.')
    }

    return $this.Schema.GetSchemaSDL()
  }

  hidden [bool] databaseExists() {
    if ($this.ConnectionString -match ':memory:') {
      return $true
    } else {
      $dbFilePath = Join-Path -Path $this.DatabasePath -ChildPath $this.DatabaseFile
      return [IO.File]::Exists($dbFilePath)
    }
  }

  hidden [void] removeDatabase() {
    if ($this.databaseExists() -and $this.ConnectionString -notmatch ':memory:') {
      $DatabasePathFolder = [SqliteHelper]::GetAbsolutePath($this.DatabasePath)
      $dbFilePath = Join-Path -Path $DatabasePathFolder -ChildPath $this.DatabaseFile
      if (![IO.File]::Exists($dbFilePath)) {

        #can't find the file but $this.databaseExists() returned true
        Write-Host ('[WARNING] Database path does not exist: {0}.' -f $dbFilePath)
      } else {
        Write-Verbose -Message ('Removing existing database file at {0}' -f $dbFilePath)
        Remove-Item -Path $dbFilePath -Force -ErrorAction Stop
      }
    } else {
      Write-Verbose -Message 'No existing database file to remove.'
    }
  }

  hidden [void] UpdateDBSchema() {
    Write-Verbose -Message ('Creating database at {0}' -f (Join-Path -Path $this.DatabasePath -ChildPath $this.DatabaseFile))
    try {
      $dbconnection = [SqliteHelper]::NewConnection($this.ConnectionString)
      $dbconnection.Open()
      Write-Verbose -Message 'Database connection opened successfully.'
      $dbcommand = $this.GetDatabaseSDL()
      [SqliteHelper]::InvokeSqliteQuery($dbconnection, $dbcommand)
      [SqliteHelper]::InvokeSqliteQuery($dbconnection, 'CREATE TABLE IF NOT EXISTS _metadata (key TEXT PRIMARY KEY, value TEXT);')
      [SqliteHelper]::InvokeSqliteQuery($dbconnection, ('INSERT OR REPLACE INTO _metadata (key, value) VALUES (''version'', ''{0}'');' -f $this.Version))
      Write-Verbose -Message ('Database schema created successfully with version {0}.' -f $this.Version)
    } catch {
      throw [System.InvalidOperationException]::new('Failed to update database: ' + $_.Exception.Message)
    } finally {
      try {
        $dbconnection.Close()
        $dbconnection.Dispose()
        [Microsoft.Data.Sqlite.SqliteConnection]::ClearAllPools()
        Write-Verbose -Message 'Database connection closed.'
      } catch {
        Write-Host 'Failed to close the database connection.'
      }
    }
  }

  hidden [void] createDatabase() {
    Write-Verbose -Message 'Creating database...'
    $this.createDatabase($false, $false)
  }

  hidden [void] createDatabase([bool]$Force) {
    Write-Verbose -Message ('Creating database with Force={0}... (no schema update)' -f $Force)
    $this.createDatabase($Force, $true)
  }

  hidden [void] createDatabase([bool]$Force, [bool]$SkipSchemaUpdate) {
    if ($this.databaseExists() -and !$Force) {
      throw [System.InvalidOperationException]::new('Database already exists. Use Force to overwrite.')
    } elseif ($this.databaseExists() -and $Force) {
      $this.removeDatabase()
    } else {
      if (![IO.Directory]::Exists($this.DatabasePath)) {
        Write-Verbose -Message ('Creating database path at {0}' -f $this.DatabasePath)
        New-Item -Path $this.DatabasePath -ItemType Directory -Force | Out-Null
      }
    }

    if (!$SkipSchemaUpdate) {
      $this.updateDBSchema()
    }
  }
}


[SQLiteDBConfig]$script:DBConfig = $null
# Main class
class SqliteHelper {
  # --- Connection Management ---
  static [Microsoft.Data.Sqlite.SqliteConnection] NewConnection([string]$ConnectionString) {
    return [Microsoft.Data.Sqlite.SqliteConnection]::new($ConnectionString)
  }

  static [void] CloseConnection() {
    [Microsoft.Data.Sqlite.SqliteConnection]::ClearAllPools()
  }
  static [void] CloseConnection([Microsoft.Data.Sqlite.SqliteConnection]$Connection) {
    if ($null -ne $Connection) {
      $Connection.Close()
      if ($Connection.State.value__ -eq 1) {
        # FORCEFULLY update the state
        $Connection.PsObject.Properties.Add([PSNoteProperty]::new("State", [System.Data.ConnectionState]::Closed))
      }
      [Microsoft.Data.Sqlite.SqliteConnection]::ClearPool($Connection)
    }
  }

  # --- Configuration ---
  static [SQLiteDBConfig] GetSqliteDBConfig([string]$Path) {
    $absPath = [SqliteHelper]::GetAbsolutePath($Path)
    if (![IO.File]::Exists($absPath)) {
      throw [System.IO.FileNotFoundException]::new("Configuration file not found: $absPath")
    }
    return [SQLiteDBConfig]::new($absPath)
  }

  static [string] GetAbsolutePath([string]$Path) {
    $upath = [System.IO.Path]::IsPathRooted($Path) ? $Path : [PsModuleBase]::GetUnResolvedPath($Path)
    return $([IO.Path]::Exists($upath) ? $upath : [PsModuleBase]::GetResolvedPath($Path))
  }

  static [string] ExpandString([string]$String) {
    if ([string]::IsNullOrWhiteSpace($String)) { return $String }
    return [System.Environment]::ExpandEnvironmentVariables($String)
  }

  # --- Database Operations ---
  static [void] InitializeSqliteDatabase([SQLiteDBConfig]$Config) {
    [SqliteHelper]::InitializeSqliteDatabase($Config, [DBMigrationMode]::INCREMENTAL)
  }
  static [void] InitializeSqliteDatabase([SQLiteDBConfig]$Config, [DBMigrationMode]$MigrationMode) {
    [SqliteHelper]::InitializeSqliteDatabase($Config, $MigrationMode, $false)
  }
  static [void] InitializeSqliteDatabase([SQLiteDBConfig]$Config, [DBMigrationMode]$MigrationMode, [bool]$Force) {
    if ($null -eq $Config) { throw [ArgumentNullException]::new('Config') }

    if ($Force) { $MigrationMode = [DBMigrationMode]::OVERWRITE }

    if (!$Config.databaseExists()) {
      $Config.createDatabase()
    } else {
      # For simplicity in this refactor, we'll trigger update if versions differs or incremental is requested
      if ($MigrationMode -eq [DBMigrationMode]::INCREMENTAL) {
        $Config.updateDBSchema()
      } elseif ($MigrationMode -eq [DBMigrationMode]::OVERWRITE) {
        $Config.removeDatabase()
        $Config.createDatabase()
      }
    }
  }
  static [object] InvokeSqliteQuery([Microsoft.Data.Sqlite.SqliteConnection]$Connection, [string]$Query) {
    return [SqliteHelper]::InvokeSqliteQuery($Connection, $Query, @{}, 'DataTable')
  }
  static [object] InvokeSqliteQuery([Microsoft.Data.Sqlite.SqliteConnection]$Connection, [string]$Query, [hashtable]$Parameters = @{}, [string]$As) {
    if ($Connection.State -ne [System.Data.ConnectionState]::Open) {
      $Connection.Open()
    }

    $command = $Connection.CreateCommand()
    $command.CommandText = $Query
    foreach ($key in $Parameters.Keys) {
      $param = $command.CreateParameter()
      $param.ParameterName = $key
      $param.Value = $Parameters[$key]
      [void]$command.Parameters.Add($param)
    }

    try {
      if ($As -eq 'DataTable') {
        $reader = $command.ExecuteReader()
        $table = [System.Data.DataTable]::new()
        $table.Load($reader)
        return $table
      } elseif ($As -eq 'PSCustomObject') {
        $reader = $command.ExecuteReader()
        $table = [System.Data.DataTable]::new()
        $table.Load($reader)
        $results = New-Object System.Collections.Generic.List[PSCustomObject]
        foreach ($row in $table.Rows) {
          $obj = New-Object PSCustomObject
          foreach ($col in $table.Columns) {
            $val = if ($row[$col] -is [System.DBNull]) { $null } else { $row[$col] }
            $obj | Add-Member -MemberType NoteProperty -Name $col.ColumnName -Value $val
          }
          $results.Add($obj)
        }
        return $results.ToArray()
      } else {
        return $command.ExecuteNonQuery()
      }
    } finally {
      $command.Dispose()
    }
  }
  static [PSCustomObject[]] GetRow([SQLiteDBConfig]$Config, [string]$TableName, [hashtable]$ClauseData) {
    return [SqliteHelper]::GetRow($Config, $TableName, $ClauseData, $null)
  }
  static [PSCustomObject[]] GetRow([SQLiteDBConfig]$Config, [string]$TableName, [hashtable]$ClauseData, [Microsoft.Data.Sqlite.SqliteConnection]$Connection) {
    if ($null -eq $Connection) {
      $Connection = [SqliteHelper]::NewConnection($Config.ConnectionString)
    }

    $sqlParameters = @{}
    $sb = [System.Text.StringBuilder]::new()
    [void]$sb.AppendLine("SELECT * FROM $TableName WHERE 1=1")

    if ($null -ne $ClauseData) {
      foreach ($key in $ClauseData.Keys) {
        $paramName = $key -replace '[^a-zA-Z0-9]', ''
        if ($ClauseData[$key] -match '\*') {
          [void]$sb.AppendLine(" AND $key LIKE @$paramName COLLATE NOCASE")
          $sqlParameters[$paramName] = $ClauseData[$key] -replace '\*', '%'
        } else {
          [void]$sb.AppendLine(" AND $key = @$paramName COLLATE NOCASE")
          $sqlParameters[$paramName] = $ClauseData[$key]
        }
      }
    }

    return [SqliteHelper]::InvokeSqliteQuery($Connection, $sb.ToString(), $sqlParameters, 'PSCustomObject')
  }
  static [PSCustomObject] NewRow([SQLiteDBConfig]$Config, [string]$TableName, [hashtable]$RowData) {
    return [SqliteHelper]::NewRow($Config, $TableName, $RowData, $null)
  }
  static [PSCustomObject] NewRow([SQLiteDBConfig]$Config, [string]$TableName, [hashtable]$RowData, [Microsoft.Data.Sqlite.SqliteConnection]$Connection) {
    if ($null -eq $Connection) {
      $Connection = [SqliteHelper]::NewConnection($Config.ConnectionString)
    }

    $sb = [System.Text.StringBuilder]::new()
    $cols = $RowData.Keys -join ', '
    $params = $RowData.Keys.ForEach{ "@$($_ -replace '[^a-zA-Z0-9]', '')" } -join ', '
    [void]$sb.AppendLine("INSERT INTO $TableName ($cols) VALUES ($params) RETURNING *;")

    $sqlParameters = @{}
    foreach ($key in $RowData.Keys) {
      $sqlParameters[($key -replace '[^a-zA-Z0-9]', '')] = $RowData[$key]
    }

    $results = [SqliteHelper]::InvokeSqliteQuery($Connection, $sb.ToString(), $sqlParameters, 'PSCustomObject')
    if ($results.Count -gt 0) { return $results[0] } else { return $null }
  }
  static [void] SetRow([SQLiteDBConfig]$Config, [string]$TableName, [hashtable]$RowData, [hashtable]$ClauseData) {
    [SqliteHelper]::SetRow($Config, $TableName, $RowData, $ClauseData, $null)
  }
  static [void] SetRow([SQLiteDBConfig]$Config, [string]$TableName, [hashtable]$RowData, [hashtable]$ClauseData, [Microsoft.Data.Sqlite.SqliteConnection]$Connection) {
    if ($null -eq $Connection) {
      $Connection = [SqliteHelper]::NewConnection($Config.ConnectionString)
    }

    $sb = [System.Text.StringBuilder]::new()
    [void]$sb.Append("UPDATE $TableName SET ")
    $setParts = New-Object System.Collections.Generic.List[string]
    $sqlParameters = @{}

    foreach ($key in $RowData.Keys) {
      $pName = "set_$($key -replace '[^a-zA-Z0-9]', '')"
      $setParts.Add("$key = @$pName")
      $sqlParameters[$pName] = $RowData[$key]
    }
    [void]$sb.Append(($setParts -join ', '))
    [void]$sb.Append(" WHERE 1=1")

    foreach ($key in $ClauseData.Keys) {
      $pName = "where_$($key -replace '[^a-zA-Z0-9]', '')"
      [void]$sb.Append(" AND $key = @$pName")
      $sqlParameters[$pName] = $ClauseData[$key]
    }

    [void][SqliteHelper]::InvokeSqliteQuery($Connection, $sb.ToString(), $sqlParameters, 'NonQuery')
  }
  static [void] RemoveRow([SQLiteDBConfig]$Config, [string]$TableName, [hashtable]$ClauseData) {
    [SqliteHelper]::RemoveRow($Config, $TableName, $ClauseData, $null)
  }
  static [void] RemoveRow([SQLiteDBConfig]$Config, [string]$TableName, [hashtable]$ClauseData, [Microsoft.Data.Sqlite.SqliteConnection]$Connection) {
    if ($null -eq $Connection) {
      $Connection = [SqliteHelper]::NewConnection($Config.ConnectionString)
    }

    $sb = [System.Text.StringBuilder]::new()
    [void]$sb.Append("DELETE FROM $TableName WHERE 1=1")
    $sqlParameters = @{}

    foreach ($key in $ClauseData.Keys) {
      $pName = "where_$($key -replace '[^a-zA-Z0-9]', '')"
      [void]$sb.Append(" AND $key = @$pName")
      $sqlParameters[$pName] = $ClauseData[$key]
    }

    [void][SqliteHelper]::InvokeSqliteQuery($Connection, $sb.ToString(), $sqlParameters, 'NonQuery')
  }
}
#endregion Classes

# Types that will be available to users when they import the module.
# code to export the list of classes that should be available once the module is loaded
# The type accelerators created will be ModuleName.ClassName (to avoid conflicts with other modules until you use 'using moduleName'
$typestoExport = @(
  [SqliteHelper]
  [SqliteDBConfig]
  [SqliteDBSchema]
  [SqliteCheckTableConstraint]
  [SqlitePrimaryKeyTableConstraint]
  [SqliteForeignKeyTableConstraint]
  [SqliteIndexConstraint]
  [SqliteTable]
  [SqliteColumn]
  [SqliteType]
)


$TypeAcceleratorsClass = [PsObject].Assembly.GetType('System.Management.Automation.TypeAccelerators')
foreach ($Type in $typestoExport) {
  if ($Type.FullName -in $TypeAcceleratorsClass::Get.Keys) {
    $Message = @(
      "Unable to register type accelerator '$($Type.FullName)'"
      'Accelerator already exists.'
    ) -join ' - '
    "TypeAcceleratorAlreadyExists $Message" | Write-Debug
  }
}
# Add type accelerators for every exportable type.
foreach ($Type in $typestoExport) {
  $TypeAcceleratorsClass::Add($Type.Name, $Type)
}
# Remove type accelerators when the module is removed.
$MyInvocation.MyCommand.ScriptBlock.Module.OnRemove = {
  foreach ($Type in $typestoExport) {
    $TypeAcceleratorsClass::Remove($Type.Name)
  }
}.GetNewClosure();

$scripts = @();
$Public = Get-ChildItem "$PSScriptRoot/Public" -Filter "*.ps1" -Recurse -ErrorAction SilentlyContinue
$scripts += Get-ChildItem "$PSScriptRoot/Private" -Filter "*.ps1" -ErrorAction SilentlyContinue
$scripts += $Public

foreach ($file in $scripts) {
  try {
    if ([string]::IsNullOrWhiteSpace($file.fullname)) { continue }
    . "$($file.fullname)"
  } catch {
    Write-Warning "Failed to import function $($file.BaseName): $_"
    $host.UI.WriteErrorLine($_)
  }
}

$Param = @{
  Cmdlet  = '*'
  Alias   = '*'
  Verbose = $false
}

if ($null -ne $Public -and $Public.Count -gt 0) {
  $Param.Function = $Public.BaseName
}

Export-ModuleMember @Param